From 7efce8415167702c6e7c5ad56296797d54b0423a Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 12 Oct 2026 16:29:54 +0530 Subject: [PATCH 1/7] feat: wire the PMFBY grievance plugin into quick-start [OpenAgriNet/network-adapter#57] Adds the Grievance step and support route, the PMFBY registry seed with its two actions, and a mock that copies the PMFBY demo portal. --- quick-start/.env.example | 29 ++- quick-start/bin/setup.py | 50 ++++- quick-start/bin/stack.sh | 8 +- .../config/adapters/provider.yaml.tmpl | 21 ++ .../config/adapters/routing-consumer.yaml | 1 + quick-start/docker-compose.yml | 20 ++ quick-start/mock-server/README.md | 3 +- quick-start/mock-server/mockpmfby/Dockerfile | 11 + quick-start/mock-server/mockpmfby/go.mod | 3 + quick-start/mock-server/mockpmfby/main.go | 198 ++++++++++++++++++ 10 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 quick-start/mock-server/mockpmfby/Dockerfile create mode 100644 quick-start/mock-server/mockpmfby/go.mod create mode 100644 quick-start/mock-server/mockpmfby/main.go diff --git a/quick-start/.env.example b/quick-start/.env.example index 3b6d9a10..b29ff74c 100644 --- a/quick-start/.env.example +++ b/quick-start/.env.example @@ -4,8 +4,8 @@ # # One line per variable, and one meaning per name. Where a name carries a # prefix, the prefix says WHOSE the value is: MAUSAMGRAM_/AGMARKNET_/VISTAAR_/ -# POCRA_ are the four upstream entities, CONSUMER_/NETWORK_/PROVIDER_ are the -# three adapters, REGISTRY_/KEYCLOAK_/DISCOVERY_ are the services. +# POCRA_/PMFBY_ are the five upstream entities, CONSUMER_/NETWORK_/PROVIDER_ are +# the three adapters, REGISTRY_/KEYCLOAK_/DISCOVERY_ are the services. # # Every credential below is a shipped default, which means it is public. Change # all of them before the stack is reachable by anyone but you. The adapter @@ -24,9 +24,11 @@ DISCOVERY_CONTEXT=../../discovery-service # subscription. Uncomment only if you have one. #DISCOVERY_BUILD_IMAGE=dhi.io/golang:1.27-alpine-dev #DISCOVERY_RUNTIME_IMAGE=dhi.io/static:20260611-musl-alpine -# The two mock upstreams. No capability uses one; `make up` does not start them. +# The mock upstreams. No capability uses one; `make up` does not start them. MOCKIMD_IMAGE=ghcr.io/nisargabd/oan-mockimd:latest MOCKAGMARKNET_IMAGE=ghcr.io/nisargabd/oan-mockagmarknet:latest +# Built locally: docker build -t oan-mockpmfby:local mock-server/mockpmfby +MOCKPMFBY_IMAGE=oan-mockpmfby:local # ---- ports ----------------------------------------------------------------- # All published on 127.0.0.1 except the reverse proxy's 80/443, and the loopback @@ -42,6 +44,7 @@ CONSUMER_ADAPTER_PORT=9202 # Only for looking at a mock directly; the adapter reaches it by service name. MOCKIMD_PORT=9100 MOCKAGMARKNET_PORT=9101 +MOCKPMFBY_PORT=9102 # The reverse-proxy profile (nginx-proxy-manager). 80/443 are public, admin is not. REVERSE_PROXY_HTTP_PORT=80 REVERSE_PROXY_HTTPS_PORT=443 @@ -149,9 +152,9 @@ CONSUMER_BASE_URL=https://consumer.oan.dev NETWORK_BASE_URL=https://network.oan.dev PROVIDER_BASE_URL=https://provider.oan.dev -# ---- the four upstream providers ------------------------------------------- -# setup.py creates all four plus a capability binding each -- seven participants -# and four bindings in all. The registry is unreachable from outside the stack, +# ---- the five upstream providers ------------------------------------------- +# setup.py creates all five plus a capability binding each -- eight participants +# and five bindings in all. The registry is unreachable from outside the stack, # so there is no second way to create them. # # THE BINDING KEY IS |. The provider adapter builds @@ -160,7 +163,7 @@ PROVIDER_BASE_URL=https://provider.oan.dev # what keeps the two from disagreeing. Change one, re-run setup.py. # A mismatch is a 404, either NET_ENTITY_NOT_FOUND or "no active record". # -# All four are REAL external APIs -- the stack needs egress to each. +# All five are REAL external APIs -- the stack needs egress to each. # WeatherObservation, from IMD Mausamgram. One ready-made header value, "Basic " # prefix included, sent verbatim (authScheme header) -- IMD issues a single @@ -211,3 +214,15 @@ POCRA_CAPABILITY=openagrinet:AgricultureFacility POCRA_BASE_URL=https://middleware-bap-client.mahapocra.gov.in POCRA_PATH=/search POCRA_MAPPING_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/0.0.1/quick-start/config/mappings/pocra/agriculture-facility.select.yaml + +# PMFBYGrievance, from PMFBY's grievance service (KRPH FGMS). authScheme +# tokenHeader: the adapter logs in at TOKEN_URL and sends the token bare in +# Authorization. BASE_URL is the host alone; the paths carry /krphapi/FGMS. +PMFBY_PARTICIPANT_ID=pmfby +PMFBY_CAPABILITY=openagrinet:PMFBYGrievance +PMFBY_BASE_URL= +PMFBY_TOKEN_URL= +PMFBY_ACCESS_UID= +PMFBY_ACCESS_PWD= +# The directory holding grievance.support.yaml and grievance.status.yaml. +PMFBY_MAPPING_BASE_URL=https://raw.githubusercontent.com/OpenAgriNet/network-adapter/development/config/mappings/pmfby \ No newline at end of file diff --git a/quick-start/bin/setup.py b/quick-start/bin/setup.py index 541ef06e..da387065 100755 --- a/quick-start/bin/setup.py +++ b/quick-start/bin/setup.py @@ -125,6 +125,9 @@ def is_placeholder(value): "VISTAAR_CLIENT_SECRET", "VISTAAR_MAPPING_URL", "POCRA_PARTICIPANT_ID", "POCRA_CAPABILITY", "POCRA_BASE_URL", "POCRA_PATH", "POCRA_MAPPING_URL", + "PMFBY_PARTICIPANT_ID", "PMFBY_CAPABILITY", "PMFBY_BASE_URL", + "PMFBY_TOKEN_URL", "PMFBY_ACCESS_UID", "PMFBY_ACCESS_PWD", + "PMFBY_MAPPING_BASE_URL", ] @@ -323,7 +326,7 @@ def upstream(participant_id, name, base_url): def ensure_binding(bearer, participant_id, capability, path, mapping_url, - method="GET"): + method="GET", actions=None): """Create a capability binding only when absent. actions is a list, not a map: the registry treats every nested object as an @@ -336,7 +339,9 @@ def ensure_binding(bearer, participant_id, capability, path, mapping_url, method is a parameter because it is the provider's contract, not a network convention: Mausamgram and Agmarknet answer a GET whose query the mapping builds, while the knowledge provider and POCRA take a POST with a JSON body. - The adapter reads it from this row, so nothing about it is compiled in.""" + The adapter reads it from this row, so nothing about it is compiled in. + + actions, when given, replaces the single select.""" binding = f"{participant_id}|{capability}" if search("ProviderSchema", {"bindingKey": {"eq": binding}}): print(f" {binding}: already present") @@ -344,10 +349,10 @@ def ensure_binding(bearer, participant_id, capability, path, mapping_url, result = post("ProviderSchema", { "bindingKey": binding, "participantId": participant_id, "capabilityCode": capability, "status": "active", - "actions": [{"action": "select", "method": method, "path": path, - "mappings": mapping_url, - "timeoutMs": 15000, "retryMax": 2, - "status": "active"}]}, bearer) + "actions": actions or [{"action": "select", "method": method, "path": path, + "mappings": mapping_url, + "timeoutMs": 15000, "retryMax": 2, + "status": "active"}]}, bearer) print(f" {binding}: {result['params']['status']} " f"{result['params'].get('errmsg', '')[:160]}") @@ -367,6 +372,18 @@ def ensure_binding(bearer, participant_id, capability, path, mapping_url, ) +def pmfby_actions(): + """PMFBY's support and status. support is never retried: a retry files a + duplicate grievance.""" + base = env("PMFBY_MAPPING_BASE_URL") + return [{"action": action, "method": "POST", "path": path, + "mappings": f"{base}/grievance.{action}.yaml", + "timeoutMs": 30000, "retryMax": retries, "status": "active"} + for action, path, retries in ( + ("support", "/krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket", 0), + ("status", "/krphapi/FGMS/GetGrievenceTicketsStatus", 2))] + + def schema_url(capability, version, ref): """Build a pack URL from the capability code, which already contains the pack directory's name: openagrinet:WeatherObservation is served out of @@ -443,7 +460,7 @@ def seed(identities): # No default base URL for weather any more: falling back to the mock's # service name would seed a row that cannot serve the basic auth the # capability is configured with, and the registry cannot update it after. - print("registry: four upstream providers") + print("registry: five upstream providers") weather = env("MAUSAMGRAM_PARTICIPANT_ID") ensure_participant(bearer, weather, upstream(weather, "IMD Mausamgram NWP", @@ -474,11 +491,15 @@ def seed(identities): upstream(pocra, "POCRA agriculture facilities", env("POCRA_BASE_URL"))) + pmfby = env("PMFBY_PARTICIPANT_ID") + ensure_participant(bearer, pmfby, + upstream(pmfby, "PMFBY grievance portal", env("PMFBY_BASE_URL"))) + # And what each of them answers. The binding key is participantId piped to # capabilityCode, and it has to match what the provider adapter was # rendered with -- both come from the same .env, which is what keeps them # from disagreeing. - print("registry: four capability bindings") + print("registry: five capability bindings") ensure_binding(bearer, weather, env("MAUSAMGRAM_CAPABILITY"), env("MAUSAMGRAM_PATH", "/get-daily"), env("MAUSAMGRAM_MAPPING_URL")) ensure_binding(bearer, mandi, env("AGMARKNET_CAPABILITY"), @@ -490,6 +511,8 @@ def seed(identities): ensure_binding(bearer, pocra, env("POCRA_CAPABILITY"), env("POCRA_PATH"), env("POCRA_MAPPING_URL"), method="POST") + ensure_binding(bearer, pmfby, env("PMFBY_CAPABILITY"), + None, None, actions=pmfby_actions()) # And what each capability MEANS, which is a different question from who # serves it. A binding above says mausamgram answers WeatherObservation @@ -565,6 +588,7 @@ def render(identities): vistaar_binding = (f"{env('VISTAAR_PARTICIPANT_ID')}" f"|{env('VISTAAR_CAPABILITY')}") pocra_binding = f"{env('POCRA_PARTICIPANT_ID')}|{env('POCRA_CAPABILITY')}" + pmfby_binding = f"{env('PMFBY_PARTICIPANT_ID')}|{env('PMFBY_CAPABILITY')}" for role in ROLES: identity = identities[role] template = (ADAPTERS / f"{role}.yaml.tmpl").read_text() @@ -592,6 +616,9 @@ def render(identities): ("__AGMARKNET_PARTICIPANT_ID__", env("AGMARKNET_PARTICIPANT_ID")), ("__VISTAAR_PARTICIPANT_ID__", env("VISTAAR_PARTICIPANT_ID")), ("__POCRA_PARTICIPANT_ID__", env("POCRA_PARTICIPANT_ID")), + ("__PMFBY_BINDING_KEY__", pmfby_binding), + ("__PMFBY_PARTICIPANT_ID__", env("PMFBY_PARTICIPANT_ID")), + ("__PMFBY_TOKEN_URL__", env("PMFBY_TOKEN_URL")), # Telemetry. One switch drives all three signals: with every # one false the plugin builds no exporter and never dials, so # a stack running without the observability profile stays @@ -629,19 +656,20 @@ def render(identities): KEYS.write_text(json.dumps(identities, indent=2)) render(identities) print(f""" -ready. The registry holds seven participants and four capability bindings, and +ready. The registry holds eight participants and five capability bindings, and the adapter configs are rendered, so nothing further has to be created by hand. {env('MAUSAMGRAM_PARTICIPANT_ID')}|{env('MAUSAMGRAM_CAPABILITY')} {env('AGMARKNET_PARTICIPANT_ID')}|{env('AGMARKNET_CAPABILITY')} {env('VISTAAR_PARTICIPANT_ID')}|{env('VISTAAR_CAPABILITY')} {env('POCRA_PARTICIPANT_ID')}|{env('POCRA_CAPABILITY')} + {env('PMFBY_PARTICIPANT_ID')}|{env('PMFBY_CAPABILITY')} Those are the binding keys the provider adapter answers to. They were rendered into its config from the same .env this seeded the registry from, which is what keeps the two from disagreeing. A payload naming anything else is answered 404 "this module serves no capability matching the request" -- explicit, but it -names the request rather than the mismatch, so compare it against these four. +names the request rather than the mismatch, so compare it against these five. The left half of each key is the participant id, and it names the ENTITY that operates the upstream -- not the capability, which is already the right half, @@ -649,7 +677,7 @@ def render(identities): catalog publishes as offer.provider.id, so capability-examples/ can be run against this stack unedited. -All four upstreams are real external providers reached over the internet, so +All five upstreams are real external providers reached over the internet, so this stack needs egress to each of them and no capability depends on a mock. Repointing one is an .env edit and a re-run of this, but it takes a NEW participant id: the registry cannot update a row and its delete is soft and diff --git a/quick-start/bin/stack.sh b/quick-start/bin/stack.sh index 1e7ac6e1..688d1494 100755 --- a/quick-start/bin/stack.sh +++ b/quick-start/bin/stack.sh @@ -135,10 +135,10 @@ up_setup() { # Only now do the bind-mounted config files exist. up_adapters() { - # The mocks are NOT started. All four capabilities point at real external - # upstreams, so starting them would pull two images and run two containers - # nothing calls. They are still defined in docker-compose.yml, so - # `docker compose up -d mock-imd mock-agmarknet` brings them up for anyone + # The mocks are NOT started. All five capabilities point at real external + # upstreams, so starting them would run containers nothing calls. They are + # still defined in docker-compose.yml, so `docker compose up -d mock-imd + # mock-agmarknet mock-pmfby` brings them up for anyone # debugging a mapping without the real credentials -- but that also needs a # new participant id and base URL in .env plus a setup.py re-run, because # the registry cannot repoint an existing row. diff --git a/quick-start/config/adapters/provider.yaml.tmpl b/quick-start/config/adapters/provider.yaml.tmpl index 53ab6d53..11165a29 100644 --- a/quick-start/config/adapters/provider.yaml.tmpl +++ b/quick-start/config/adapters/provider.yaml.tmpl @@ -216,6 +216,26 @@ modules: # farmer there is no facility when there is. searchConcurrency: 1 + # PMFBY grievance: support files, status reads back. No OTP. + - id: Grievance + config: + bindingKeys: "__PMFBY_BINDING_KEY__" + # Paired by position, tried in order: status names the binding on + # its contract, support on its channel. + providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" + capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" + # Logs in at NICUsersLogin and sends the token bare in Authorization. + __PMFBY_PARTICIPANT_ID__: + authScheme: tokenHeader + tokenUrl: "__PMFBY_TOKEN_URL__" + tokenUserField: appAccessUID + tokenUserEnv: PMFBY_ACCESS_UID + tokenSecretField: appAccessPWD + tokenSecretEnv: PMFBY_ACCESS_PWD + tokenResponseField: responseDynamic.token.Token + tokenTtl: 10m + headerName: Authorization + # Signing and signature validation are OFF: validateSign and signAck are # absent below, so this tier accepts unsigned traffic and answers # unsigned. Turning it back on means adding the step names here AND on the @@ -228,6 +248,7 @@ modules: - MandiPrice - KnowledgeAdvisory - AgricultureFacility + - Grievance # The outbound leg: this provider's catalogue system publishing to the network. # diff --git a/quick-start/config/adapters/routing-consumer.yaml b/quick-start/config/adapters/routing-consumer.yaml index 682d9f5d..89f53748 100644 --- a/quick-start/config/adapters/routing-consumer.yaml +++ b/quick-start/config/adapters/routing-consumer.yaml @@ -22,4 +22,5 @@ routingRules: - select - init - confirm + - support - status diff --git a/quick-start/docker-compose.yml b/quick-start/docker-compose.yml index 9568abc3..4d676033 100644 --- a/quick-start/docker-compose.yml +++ b/quick-start/docker-compose.yml @@ -586,6 +586,23 @@ services: ports: - "127.0.0.1:${MOCKAGMARKNET_PORT}:9101" + # PMFBY grievance (KRPH FGMS), copying the demo portal. Accepts the + # PMFBY_ACCESS_* login from .env. Built locally: + # docker build -t oan-mockpmfby:local mock-server/mockpmfby + mock-pmfby: + image: ${MOCKPMFBY_IMAGE} + pull_policy: missing + container_name: mock-pmfby + restart: unless-stopped + logging: *logging + networks: [oan-internal] + command: + - "-addr=:9102" + - "-user=${PMFBY_ACCESS_UID}" + - "-pass=${PMFBY_ACCESS_PWD}" + ports: + - "127.0.0.1:${MOCKPMFBY_PORT}:9102" + # Verifies the caller, calls the upstream provider, answers synchronously. # It has no provider address of its own: it reads the ProviderSchema row, # so repointing it at a different upstream is a registry write, not a @@ -628,6 +645,9 @@ services: # configuration fault, not a transient one, so it is not retried. VISTAAR_CLIENT_ID: ${VISTAAR_CLIENT_ID} VISTAAR_CLIENT_SECRET: ${VISTAAR_CLIENT_SECRET} + # PMFBY's login, exchanged at NICUsersLogin for a token. + PMFBY_ACCESS_UID: ${PMFBY_ACCESS_UID} + PMFBY_ACCESS_PWD: ${PMFBY_ACCESS_PWD} volumes: - ./config/adapters/provider.yaml:/app/config/adapter.yaml:ro - ./config/adapters/routing-provider.yaml:/app/config/routing-provider.yaml:ro diff --git a/quick-start/mock-server/README.md b/quick-start/mock-server/README.md index 6b662ee8..927c26c0 100644 --- a/quick-start/mock-server/README.md +++ b/quick-start/mock-server/README.md @@ -1,12 +1,13 @@ # Mock providers -Two stand-in upstreams, so the stack can be exercised end to end without +Three stand-in upstreams, so the stack can be exercised end to end without credentials for the real services. | mock | stands in for | port | auth | |---|---|---|---| | `mockimd` | IMD Mausamgram NWP | 9100 | basic | | `mockagmarknet` | Agmarknet Vistaar | 9101 | token as a query parameter | +| `mockpmfby` | PMFBY grievance (KRPH FGMS) | 9102 | token from `/NICUsersLogin`, sent bare in `Authorization` | ## Why the sources are here diff --git a/quick-start/mock-server/mockpmfby/Dockerfile b/quick-start/mock-server/mockpmfby/Dockerfile new file mode 100644 index 00000000..3e1ce343 --- /dev/null +++ b/quick-start/mock-server/mockpmfby/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26.1-bookworm AS builder +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 go build -o mockpmfby . + +FROM cgr.dev/chainguard/wolfi-base:latest +WORKDIR /app +COPY --from=builder /src/mockpmfby . +EXPOSE 9102 +ENTRYPOINT ["./mockpmfby"] diff --git a/quick-start/mock-server/mockpmfby/go.mod b/quick-start/mock-server/mockpmfby/go.mod new file mode 100644 index 00000000..2d66b935 --- /dev/null +++ b/quick-start/mock-server/mockpmfby/go.mod @@ -0,0 +1,3 @@ +module mockpmfby + +go 1.22.2 diff --git a/quick-start/mock-server/mockpmfby/main.go b/quick-start/mock-server/mockpmfby/main.go new file mode 100644 index 00000000..82ecbcb4 --- /dev/null +++ b/quick-start/mock-server/mockpmfby/main.go @@ -0,0 +1,198 @@ +// Command mockpmfby stands in for PMFBY's grievance service (KRPH FGMS), +// copying pmfbydemo.amnex.co.in as observed on 2026-10-12. +// +// Every answer is HTTP 200 in FGMS's envelope: responseCode "1" is success and +// "0" a refusal carrying the portal's message. The token from /NICUsersLogin +// is sent bare in Authorization. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" +) + +const basePath = "/krphapi/FGMS" + +// application is an enrolment the portal knows, with the phone it was made from. +type application struct { + no, phone, farmer, crop string +} + +// The demo's application, which already has a ticket, and one without, so a +// repeat lodge and a fresh one can both be exercised. +var applications = []application{ + {"040108251010160770605", "9001361774", "Fateh Singh", "Green Gram (Moong)"}, + {"040108251010160770606", "9591931134", "Mock Farmer", "Paddy"}, +} + +var ( + mu sync.Mutex + // One ticket per application, as the demo keeps. + tickets = map[string]map[string]any{ + applications[0].no: record("100626000099001", 1, "2026-05-11", "Claim is not recieved by me.", applications[0]), + } + nextNo = 100626000099002 + nextID = 2 +) + +func main() { + addr := flag.String("addr", ":9102", "address to listen on") + user := flag.String("user", "NCIP_USER", "appAccessUID the login accepts") + pass := flag.String("pass", "pmfby-password", "appAccessPWD the login accepts") + token := flag.String("token", "local-pmfby-token", "token the login issues") + flag.Parse() + + handle("NICUsersLogin", "", func(body map[string]any) envelope { + if missing := absent(body, "appAccessUID", "appAccessPWD"); missing != "" { + return refused(missing + " is required") + } + if body["appAccessUID"] != *user || body["appAccessPWD"] != *pass { + return refused("UserName or Password does not match!") + } + now, until := time.Now().UTC(), time.Now().UTC().Add(30*time.Minute) + return envelope{ResponseCode: "1", ResponseMessage: "SUCCESS", ResponseDynamic: map[string]any{ + "AppAccessUID": *user, "rcode": 1, "rmessage": "SUCCESS", + "token": map[string]any{ + "Token": *token, + "expirationTime": float64(until.UnixMilli()) / 1000, + "validFrom": now.Format(time.RFC3339Nano), + "validTo": until.Format(time.RFC3339Nano), + }, + }} + }) + + handle("AddKRPHNCIPGrievenceSupportTicket", *token, func(body map[string]any) envelope { + if missing := absent(body, "requestorMobileNo", "complaintDate", "receiptSourceID", "ticketCategoryID", + "ticketSubCategoryID", "requestYear", "requestSeason", "applicationNo", "grievenceDescription"); missing != "" { + return refused(missing + " is required") + } + app, known := find(fmt.Sprint(body["applicationNo"])) + if !known { + return refused("Application Details not Exists") + } + if fmt.Sprint(body["requestorMobileNo"]) != app.phone { + return refused("Mobile Number Not Matched") + } + mu.Lock() + defer mu.Unlock() + if t, raised := tickets[app.no]; raised { + return envelope{ResponseCode: "1", ResponseMessage: "Ticket Already Raised for !", ResponseDynamic: ticketRef(t)} + } + // ponytail: the demo's message for a fresh ticket is unobserved, since its + // only application already has one. + t := record(fmt.Sprint(nextNo), nextID, fmt.Sprint(body["complaintDate"]), + fmt.Sprint(body["grievenceDescription"]), app) + nextNo++ + nextID++ + tickets[app.no] = t + return envelope{ResponseCode: "1", ResponseMessage: "Ticket Raised Successfully", ResponseDynamic: ticketRef(t)} + }) + + // Matched on the phone alone: the demo ignores the ticket number unless it + // is absent, and with no phone answers its first record. + handle("GetGrievenceTicketsStatus", *token, func(body map[string]any) envelope { + phone, hasPhone := body["requestorMobileNo"] + _, hasTicket := body["GrievenceSupportTicketNo"] + mu.Lock() + defer mu.Unlock() + for _, app := range applications { + t, raised := tickets[app.no] + if raised && (!hasPhone || (hasTicket && fmt.Sprint(phone) == app.phone)) { + // A successful read is the one answer whose responseCode is a number. + return envelope{ResponseCode: 1, ResponseMessage: "Fetched successfully", ResponseDynamic: t} + } + } + return envelope{ResponseCode: "0", ResponseMessage: "No record found", ResponseDynamic: []any{}} + }) + + log.Printf("mockpmfby listening on %s%s", *addr, basePath) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Printf("mockpmfby stopped: %v", err) + os.Exit(1) + } +} + +// envelope is FGMS's answer to every call. +type envelope struct { + ResponseObject any `json:"responseObject"` + ResponseDynamic any `json:"responseDynamic"` + ResponseCode any `json:"responseCode"` + ResponseMessage string `json:"responseMessage"` + JSONString any `json:"jsonString"` + RecordCount int `json:"recordCount"` +} + +func refused(message string) envelope { return envelope{ResponseCode: "0", ResponseMessage: message} } + +// handle serves one operation. A non-empty token must arrive bare in Authorization. +func handle(operation, token string, serve func(map[string]any) envelope) { + http.HandleFunc("POST "+basePath+"/"+operation, func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + var body map[string]any + answer := refused("Unexpected token in JSON") + switch auth := r.Header.Get("Authorization"); { + case token != "" && auth == "": + answer = refused("Token required!") + case token != "" && auth != token: + answer = refused("jwt malformed") + case json.NewDecoder(r.Body).Decode(&body) == nil: + answer = serve(body) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(answer); err != nil { + log.Printf("could not write the answer: %v", err) + } + }) +} + +// absent names the first field missing or empty in body, or "" if none is. +func absent(body map[string]any, fields ...string) string { + for _, field := range fields { + if value, ok := body[field]; !ok || value == nil || value == "" { + return field + } + } + return "" +} + +func find(no string) (application, bool) { + for _, app := range applications { + if app.no == no { + return app, true + } + } + return application{}, false +} + +// record is a ticket as the read returns it, with every field the demo sends. +func record(no string, id int, filed, description string, app application) map[string]any { + return map[string]any{ + "GrievenceSupportTicketID": id, "GrievenceSupportTicketNo": no, "TicketRequestorID": nil, + "FarmerName": app.farmer, "RequestorMobileNo": app.phone, "ComplaintDate": filed, + "RepresentationType": "SINGLE", "StateCodeAlpha": "0A5BE76C-BDC2-4405-A32C-C46448FF136B", + "DistrictRequestorID": nil, "VillageRequestorID": nil, "NyayPanchayatID": nil, "NyayPanchayat": nil, + "GramPanchayatID": "", "GramPanchayat": "", "GrievenceTicketSourceTypeID": 132306, + "GrievenceSourceOtherType": nil, "TicketSubCategoryID": 10, "TicketCategoryID": 3, + "RequestYear": 2025, "RequestSeason": 1, "SocialMediaTypeID": nil, "OtherSocialMedia": nil, + "SocialMediaURL": nil, "ReceiptSourceID": 134306, "VillageName": nil, "CropName": app.crop, + "TicketStatusID": 109301, "ApplicationNo": app.no, "InsuranceCompanyID": 1, + "InsurancePolicyNo": app.no[:len(app.no)-2], "Email": nil, "SubDistrictID": nil, + "SubDistrictName": "Bhadra", "TicketSubCategoryName": "Portal Issues Login", "TicketStatus": "Open", + "InsuranceCompany": "AGRICULTURE INSURANCE COMPANY", "TicketCategoryName": "Enrollment", + "StateMasterName": "RAJASTHAN", "DistrictMasterName": "Hanumangarh", + "GrievenceDescription": description, "IsLegal": nil, "LegalRemarks": nil, + "CPGramRegistrationNumber": nil, "NCIPReferenceNumber": nil, "InsertUserID": 77777, + "InsertDateTime": time.Now().Format("2006-01-02 15:04:05"), "latestRemark": "", + } +} + +func ticketRef(t map[string]any) map[string]any { + return map[string]any{"GrievenceSupportTicketNo": t["GrievenceSupportTicketNo"], + "GrievenceSupportTicketID": t["GrievenceSupportTicketID"]} +} From 6200fa98669b55b86e2c4fcd5946aaacd87517f6 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 16:59:35 +0530 Subject: [PATCH 2/7] feat: seed the Grievance schema row from network-specs api-schemas [OpenAgriNet/network-adapter#57] The Grievance pack lives under api-schemas/ on api-schema-packs-v0.1, not schema/ on main, so the registry pattern now accepts that folder and a dotted ref. --- .../files/schemas/SchemaRegistry.json | 2 +- quick-start/bin/setup.py | 51 +++++++++++-------- .../registry/schemas/SchemaRegistry.json | 2 +- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/charts/registry/files/schemas/SchemaRegistry.json b/charts/registry/files/schemas/SchemaRegistry.json index c398d63c..10db257b 100644 --- a/charts/registry/files/schemas/SchemaRegistry.json +++ b/charts/registry/files/schemas/SchemaRegistry.json @@ -16,7 +16,7 @@ "name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "\\S" }, "version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, "schemaUrl": { "type": "string", "maxLength": 2000, - "pattern": "^https://raw\\.githubusercontent\\.com/OpenAgriNet/network-specs/[A-Za-z0-9_-]+/schema/[A-Za-z0-9]+/v[0-9]+\\.[0-9]+/[A-Za-z0-9_-]+\\.yaml$" }, + "pattern": "^https://raw\\.githubusercontent\\.com/OpenAgriNet/network-specs/[A-Za-z0-9._-]+/(schema|api-schemas)/[A-Za-z0-9]+/v[0-9]+\\.[0-9]+/[A-Za-z0-9_-]+\\.yaml$" }, "status": { "$ref": "#/definitions/Status" } } }, diff --git a/quick-start/bin/setup.py b/quick-start/bin/setup.py index da387065..e40e431c 100755 --- a/quick-start/bin/setup.py +++ b/quick-start/bin/setup.py @@ -8,18 +8,18 @@ it renders are bind-mounted files, and an adapter started before they exist leaves a directory in their place. -WHAT IT WRITES. Seven participants, four capability bindings, four schemas: +WHAT IT WRITES. Eight participants, five capability bindings, five schemas: 3 x node one per adapter -- consumer, network, provider -- each with the public halves of a keypair. The private halves stay in keys/keys.json and never reach the registry. - 4 x upstream the APIs this deployment calls. ALL FOUR are real external + 5 x upstream the APIs this deployment calls. ALL FIVE are real external hosts now, whose base URLs come from .env -- no mock is in use by any capability. An upstream signs nothing, so it needs no role and no keys. - 4 x binding a ProviderSchema row per capability: which upstream answers + 5 x binding a ProviderSchema row per capability: which upstream answers it, the method and path, timeouts, and the mapping URL. - 4 x schema a SchemaRegistry row per capability, saying what the + 5 x schema a SchemaRegistry row per capability, saying what the capability MEANS rather than who serves it: the pack in network-specs that defines its attributes. One per capability, so two providers of one capability share a row. @@ -357,18 +357,24 @@ def ensure_binding(bearer, participant_id, capability, path, mapping_url, f"{result['params'].get('errmsg', '')[:160]}") -# The four capability schemas, keyed by the .env variable each capability code +# network-specs keeps resource packs under schema/ and API packs under +# api-schemas/, each folder on its own ref. +RESOURCE_PACKS = "schema" +API_PACKS = "api-schemas" + +# The five capability schemas, keyed by the .env variable each capability code # comes from so the code itself is never written twice. # # The display name mirrors the pack's own info.title, minus its "OpenAgriNet -" # and "Attributes" framing. The version is per capability rather than one -# setting for all four, because a capability can move to v0.2 while the others +# setting for all five, because a capability can move to v0.2 while the others # stay -- that is the point of versioning the packs separately. CAPABILITY_SCHEMAS = ( - ("MAUSAMGRAM_CAPABILITY", "Weather Observation", "v0.1"), - ("AGMARKNET_CAPABILITY", "Mandi Price", "v0.1"), - ("VISTAAR_CAPABILITY", "Knowledge Advisory", "v0.1"), - ("POCRA_CAPABILITY", "Agriculture Facility", "v0.1"), + ("MAUSAMGRAM_CAPABILITY", "Weather Observation", "v0.1", RESOURCE_PACKS), + ("AGMARKNET_CAPABILITY", "Mandi Price", "v0.1", RESOURCE_PACKS), + ("VISTAAR_CAPABILITY", "Knowledge Advisory", "v0.1", RESOURCE_PACKS), + ("POCRA_CAPABILITY", "Agriculture Facility", "v0.1", RESOURCE_PACKS), + ("PMFBY_CAPABILITY", "Grievance", "v0.1", API_PACKS), ) @@ -384,21 +390,21 @@ def pmfby_actions(): ("status", "/krphapi/FGMS/GetGrievenceTicketsStatus", 2))] -def schema_url(capability, version, ref): +def schema_url(capability, version, folder, ref): """Build a pack URL from the capability code, which already contains the pack directory's name: openagrinet:WeatherObservation is served out of schema/WeatherObservation. Deriving it means the two cannot drift. The registry pattern-checks the result against exactly this shape, so a capability whose pack is laid out differently is refused rather than - stored -- which is the behaviour to want, since all eight packs in - network-specs are //attributes.yaml today.""" + stored -- which is the behaviour to want, since every pack in + network-specs is ///attributes.yaml today.""" return ("https://raw.githubusercontent.com/OpenAgriNet/network-specs/" - f"{ref}/schema/{capability.partition(':')[2]}/{version}" + f"{ref}/{folder}/{capability.partition(':')[2]}/{version}" "/attributes.yaml") -def ensure_capability_schema(bearer, capability, name, version, ref): +def ensure_capability_schema(bearer, capability, name, version, folder, ref): """Create a capability's schema row only when absent. THE REGISTRY NEVER FETCHES schemaUrl -- it only checks its shape. So a row @@ -415,7 +421,7 @@ def ensure_capability_schema(bearer, capability, name, version, ref): return result = post("SchemaRegistry", { "capabilityCode": capability, "name": name, "version": version, - "schemaUrl": schema_url(capability, version, ref), + "schemaUrl": schema_url(capability, version, folder, ref), "status": "active"}, bearer) print(f" {capability}: {result['params']['status']} " f"{result['params'].get('errmsg', '')[:160]}") @@ -436,7 +442,7 @@ def seed(identities): wait_for_registry() bearer = token() - # Seven participants and four capability bindings, all of it from here. + # Eight participants and five capability bindings, all of it from here. # # The registry is not reachable from outside this stack -- no published # port beyond loopback and no proxy host in front of it -- so there is no @@ -453,7 +459,7 @@ def seed(identities): env(f"{role.upper()}_BASE_URL"), identity["signingPublic"])) - # All four upstreams are real external providers now -- no mock is in use + # All five upstreams are real external providers now -- no mock is in use # by any capability, so every base URL comes from .env and the stack needs # egress to all of them. # @@ -524,10 +530,11 @@ def seed(identities): # SEPARATE FROM THE MAPPING URLS ABOVE, which live in helmcharts and # describe one upstream's wire format. These are network-specs and describe # the network's own vocabulary, so they move on their own ref. - print("registry: four capability schemas") - ref = env("SCHEMA_PACK_REF", "main") - for var, name, version in CAPABILITY_SCHEMAS: - ensure_capability_schema(bearer, env(var), name, version, ref) + print("registry: five capability schemas") + pack_refs = {RESOURCE_PACKS: env("SCHEMA_PACK_REF", "main"), + API_PACKS: env("API_SCHEMA_PACK_REF", "api-schema-packs-v0.1")} + for var, name, version, folder in CAPABILITY_SCHEMAS: + ensure_capability_schema(bearer, env(var), name, version, folder, pack_refs[folder]) def key_osids(identities): diff --git a/quick-start/config/registry/schemas/SchemaRegistry.json b/quick-start/config/registry/schemas/SchemaRegistry.json index c398d63c..10db257b 100644 --- a/quick-start/config/registry/schemas/SchemaRegistry.json +++ b/quick-start/config/registry/schemas/SchemaRegistry.json @@ -16,7 +16,7 @@ "name": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "\\S" }, "version": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+$" }, "schemaUrl": { "type": "string", "maxLength": 2000, - "pattern": "^https://raw\\.githubusercontent\\.com/OpenAgriNet/network-specs/[A-Za-z0-9_-]+/schema/[A-Za-z0-9]+/v[0-9]+\\.[0-9]+/[A-Za-z0-9_-]+\\.yaml$" }, + "pattern": "^https://raw\\.githubusercontent\\.com/OpenAgriNet/network-specs/[A-Za-z0-9._-]+/(schema|api-schemas)/[A-Za-z0-9]+/v[0-9]+\\.[0-9]+/[A-Za-z0-9_-]+\\.yaml$" }, "status": { "$ref": "#/definitions/Status" } } }, From b37b2027a1ba89390a727e434ae24ac84bdc9545 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 16:59:40 +0530 Subject: [PATCH 3/7] feat: serve the PMFBY grievance mappings from quick-start [OpenAgriNet/network-adapter#57] The other capabilities' mappings are served from this folder; PMFBY's now sit beside them. --- .../mappings/pmfby/grievance.status.yaml | 117 ++++++++++++++++++ .../mappings/pmfby/grievance.support.yaml | 112 +++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 quick-start/config/mappings/pmfby/grievance.status.yaml create mode 100644 quick-start/config/mappings/pmfby/grievance.support.yaml diff --git a/quick-start/config/mappings/pmfby/grievance.status.yaml b/quick-start/config/mappings/pmfby/grievance.status.yaml new file mode 100644 index 00000000..9f6edffb --- /dev/null +++ b/quick-start/config/mappings/pmfby/grievance.status.yaml @@ -0,0 +1,117 @@ +# PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. +# Reads a grievance back by ticket number and phone. Only listed fields are +# returned; the farmer's personal details are dropped. +# Required fields and formats are checked by the Grievance pack. + +request: | + ( + $attributes := beckn.message.contract.commitments[0].commitmentAttributes; + { + "requestorMobileNo": $attributes.applicantMobile, + "GrievenceSupportTicketNo": $attributes.case.ticketNo + } + ) + +# Empty PMFBY fields are dropped. No record, or one for another ticket, is a +# 400 in our words; any other failure is a 502. PMFBY's message is logged, +# never returned. +response: | + ( + /* Constants */ + $PMFBY_SUCCESS_CODE := "1"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "Direct"; + $COMMITMENT_STATUS_ACTIVE := "ACTIVE"; + $DEFAULT_CASE_STATUS := "UnderReview"; + $CASE_STATUS_CODES := { "open": "Registered", "resolved": "Resolved" }; + $SEASON_NAMES := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; + $TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"; + + /* e.g. "2026-10-12T11:02:49.000Z" */ + $RFC3339_PATTERN := /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; + $DIGITS_PATTERN := /^[0-9]+$/; + $YEAR_PATTERN := /^[0-9]{4}$/; + + $MSG_NOT_FOUND := "No grievance was found for this ticket number and phone number."; + $MSG_UNAVAILABLE := "PMFBY could not return the grievance status right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { "no record found": $MSG_NOT_FOUND }; + + /* Helpers */ + /* PMFBY sends null or "" for missing values. */ + $isText := function($value) { $type($value) = "string" and $value != "" }; + $asText := function($value) { $isText($value) ? $value }; + /* Ids come as numbers or digit strings, and 0 means none. */ + $asId := function($value) { + ($type($value) = "number" or $type($value) = "string") + and $contains($string($value), $DIGITS_PATTERN) and $number($string($value)) != 0 + ? $string($value) + }; + /* Drops an object with no fields instead of sending {}. */ + $omitIfEmpty := function($object) { $exists($keys($object)[0]) ? $object }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } + }; + + /* Inputs */ + $commitment := beckn.message.contract.commitments[0]; + $attributes := $commitment.commitmentAttributes; + /* [0] reads a bare record or a list of one alike. */ + $record := response.responseDynamic[0]; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE; + /* PMFBY reads by phone alone, so a record for another ticket is no record. */ + $isOtherTicket := $exists($record.GrievenceSupportTicketNo) + and $string($record.GrievenceSupportTicketNo) != $string($attributes.case.ticketNo); + $isFound := $isAccepted and ($isText($record.ApplicationNo) or $isText($record.TicketStatus)) and $not($isOtherTicket); + $mappedStatus := $isText($record.TicketStatus) ? $lookup($CASE_STATUS_CODES, $lowercase($trim($record.TicketStatus))); + $caseStatus := $exists($mappedStatus) ? $mappedStatus : $DEFAULT_CASE_STATUS; + $requestYear := $asId($record.RequestYear); + $cropYear := $requestYear and $contains($requestYear, $YEAR_PATTERN) ? $requestYear; + $filedOn := $isText($record.InsertDateTime) and $contains($record.InsertDateTime, $RFC3339_PATTERN) + ? $fromMillis($toMillis($record.InsertDateTime), $TIMESTAMP_FORMAT); + /* The officer's answer; "" until there is one. */ + $reply := $asText($isText($record.latestRemark) ? $trim($record.latestRemark)); + + /* Output */ + $isFound + ? { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": $COMMITMENT_STATUS_ACTIVE } }, + "commitmentAttributes": { + "@context": $attributes.`@context`, + "@type": $attributes.`@type`, + "informationMode": $INFORMATION_MODE, + "scheme": $SCHEME, + "enrolmentId": $asText($record.ApplicationNo), + "cropYear": $cropYear, + "season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)), + "grievance": { + "category": $omitIfEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }), + "subCategory": $omitIfEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }), + "description": $asText($record.GrievenceDescription) + }, + "case": { + "ticketNo": $attributes.case.ticketNo, + "status": { "code": $caseStatus }, + "filedOn": $filedOn, + "reply": $reply, + "cropName": $asText($record.CropName) + } + } + }])] + }]) + } + } + : $isAccepted ? $error($ERROR_REFUSED, $MSG_NOT_FOUND) + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) + ) diff --git a/quick-start/config/mappings/pmfby/grievance.support.yaml b/quick-start/config/mappings/pmfby/grievance.support.yaml new file mode 100644 index 00000000..f38c1554 --- /dev/null +++ b/quick-start/config/mappings/pmfby/grievance.support.yaml @@ -0,0 +1,112 @@ +# PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. +# Files a grievance; no OTP. "Grievence" is PMFBY's spelling. +# Required fields and formats are checked by the Grievance pack. +# The two halves cannot share variables, so the default codes are in both. + +request: | + ( + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $DEFAULT_RECEIPT_SOURCE_ID := "134306"; + $SEASON_CODES := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + + $DATE_FORMAT := "[Y0001]-[M01]-[D01]"; + $IST_OFFSET := "+0530"; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $filedAt := $exists($channel.complaintDate) ? $toMillis($channel.complaintDate) : $millis(); + + /* Output */ + { + "requestorMobileNo": $channel.applicantMobile, + "applicationNo": $channel.enrolmentId, + "requestYear": $string($channel.cropYear), + "requestSeason": $lookup($SEASON_CODES, $channel.season), + "ticketCategoryID": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE), + "ticketSubCategoryID": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE), + "grievenceDescription": $trim($grievance.description), + "complaintDate": $fromMillis($filedAt, $DATE_FORMAT, $IST_OFFSET), + "receiptSourceID": $orDefault($channel.receiptSourceId, $DEFAULT_RECEIPT_SOURCE_ID) + } + ) + +# Echoes the request and adds the ticket. A known PMFBY refusal is a 400 in our +# words; any other failure is a 502. PMFBY's message is logged, never returned. +response: | + ( + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $PMFBY_SUCCESS_CODE := "1"; + $PROVIDER_NAME := "PMFBY Grievance Portal"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "Direct"; + $CASE_STATUS_REGISTERED := "Registered"; + $TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"; + + $MSG_PHONE_MISMATCH := "The phone number does not match the one registered for this application number."; + $MSG_APPLICATION_NOT_FOUND := "No PMFBY application was found for this application number."; + $MSG_UNAVAILABLE := "PMFBY could not register the grievance right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { + "mobile number not matched": $MSG_PHONE_MISMATCH, + "application details not exists": $MSG_APPLICATION_NOT_FOUND + }; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } + }; + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $hasTicket := $type($ticketNo) = "number" or ($type($ticketNo) = "string" and $ticketNo != ""); + /* "Ticket Already Raised" also comes back as success with a ticket. */ + $isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE and $hasTicket; + + /* Output */ + $isLodged + ? { + "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), + "message": { + "support": { + "channels": [{ + "@context": $channel.`@context`, + "@type": $channel.`@type`, + "informationMode": $INFORMATION_MODE, + "provider": { "id": $channel.provider.id, "descriptor": { "name": $PROVIDER_NAME } }, + "scheme": $SCHEME, + "enrolmentId": $channel.enrolmentId, + "grievance": { + "category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) }, + "subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) }, + "description": $trim($grievance.description) + }, + "case": { + "ticketNo": $string($ticketNo), + "status": { "code": $CASE_STATUS_REGISTERED }, + /* Lodge time, so it agrees with what a status read reports. */ + "filedOn": $fromMillis($millis(), $TIMESTAMP_FORMAT) + } + }] + } + } + } + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) + ) From 2b5a1b42b0ab78dec205729dd505d93a79366699 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 16:59:40 +0530 Subject: [PATCH 4/7] fix: hold the PMFBY token until the login reply's validTo [OpenAgriNet/network-adapter#57] The plugin reads the token lifetime from the login reply, so a fixed tokenTtl is no longer needed. --- quick-start/config/adapters/provider.yaml.tmpl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/quick-start/config/adapters/provider.yaml.tmpl b/quick-start/config/adapters/provider.yaml.tmpl index 11165a29..ba1da63b 100644 --- a/quick-start/config/adapters/provider.yaml.tmpl +++ b/quick-start/config/adapters/provider.yaml.tmpl @@ -224,7 +224,8 @@ modules: # its contract, support on its channel. providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" - # Logs in at NICUsersLogin and sends the token bare in Authorization. + # Logs in at NICUsersLogin and sends the token bare in Authorization, + # held until the login reply's validTo. __PMFBY_PARTICIPANT_ID__: authScheme: tokenHeader tokenUrl: "__PMFBY_TOKEN_URL__" @@ -233,7 +234,7 @@ modules: tokenSecretField: appAccessPWD tokenSecretEnv: PMFBY_ACCESS_PWD tokenResponseField: responseDynamic.token.Token - tokenTtl: 10m + tokenExpiryField: responseDynamic.token.validTo headerName: Authorization # Signing and signature validation are OFF: validateSign and signAck are From 8d2b7850a9324ec4651aa084096f17a513573b23 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 16:59:47 +0530 Subject: [PATCH 5/7] fix: date mock PMFBY records in RFC 3339 and echo the lodged values [OpenAgriNet/network-adapter#57] The status mapping only reads an RFC 3339 InsertDateTime, so the mock's reads had no filedOn. The seed ticket is now the demo's current one. --- quick-start/mock-server/mockpmfby/main.go | 58 +++++++++++++++++------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/quick-start/mock-server/mockpmfby/main.go b/quick-start/mock-server/mockpmfby/main.go index 82ecbcb4..76d04aba 100644 --- a/quick-start/mock-server/mockpmfby/main.go +++ b/quick-start/mock-server/mockpmfby/main.go @@ -1,5 +1,5 @@ // Command mockpmfby stands in for PMFBY's grievance service (KRPH FGMS), -// copying pmfbydemo.amnex.co.in as observed on 2026-10-12. +// copying pmfbydemo.amnex.co.in as observed on 2026-10-23. // // Every answer is HTTP 200 in FGMS's envelope: responseCode "1" is success and // "0" a refusal carrying the portal's message. The token from /NICUsersLogin @@ -13,6 +13,7 @@ import ( "log" "net/http" "os" + "strconv" "sync" "time" ) @@ -27,18 +28,31 @@ type application struct { // The demo's application, which already has a ticket, and one without, so a // repeat lodge and a fresh one can both be exercised. var applications = []application{ - {"040108251010160770605", "9001361774", "Fateh Singh", "Green Gram (Moong)"}, + {"040108251010160770608", "9001361774", "Fateh Singh", "Green Gram (Moong)"}, {"040108251010160770606", "9591931134", "Mock Farmer", "Paddy"}, } +// The only category names the demo has shown; the full list is still awaited. +var ( + categoryNames = map[int]string{3: "Enrollment"} + subCategoryNames = map[int]string{10: "Portal Issues Login"} +) + +// demoLodge is the lodge behind the demo's existing ticket. +var demoLodge = map[string]any{ + "complaintDate": "2026-05-11", "requestYear": "2025", "requestSeason": 1, + "ticketCategoryID": 3, "ticketSubCategoryID": 10, "grievenceDescription": "Claim is not recieved by me.", +} + var ( mu sync.Mutex // One ticket per application, as the demo keeps. tickets = map[string]map[string]any{ - applications[0].no: record("100626000099001", 1, "2026-05-11", "Claim is not recieved by me.", applications[0]), + applications[0].no: record("121026000099003", 13, + time.Date(2026, 10, 12, 11, 2, 49, 0, time.UTC), demoLodge, applications[0]), } - nextNo = 100626000099002 - nextID = 2 + nextNo = 121026000099004 + nextID = 14 ) func main() { @@ -86,8 +100,7 @@ func main() { } // ponytail: the demo's message for a fresh ticket is unobserved, since its // only application already has one. - t := record(fmt.Sprint(nextNo), nextID, fmt.Sprint(body["complaintDate"]), - fmt.Sprint(body["grievenceDescription"]), app) + t := record(fmt.Sprint(nextNo), nextID, time.Now(), body, app) nextNo++ nextID++ tickets[app.no] = t @@ -171,24 +184,26 @@ func find(no string) (application, bool) { } // record is a ticket as the read returns it, with every field the demo sends. -func record(no string, id int, filed, description string, app application) map[string]any { +// Year, season and categories come back as the numbers they were lodged with. +func record(no string, id int, insertedAt time.Time, lodge map[string]any, app application) map[string]any { + category, subCategory := number(lodge["ticketCategoryID"]), number(lodge["ticketSubCategoryID"]) return map[string]any{ "GrievenceSupportTicketID": id, "GrievenceSupportTicketNo": no, "TicketRequestorID": nil, - "FarmerName": app.farmer, "RequestorMobileNo": app.phone, "ComplaintDate": filed, + "FarmerName": app.farmer, "RequestorMobileNo": app.phone, "ComplaintDate": lodge["complaintDate"], "RepresentationType": "SINGLE", "StateCodeAlpha": "0A5BE76C-BDC2-4405-A32C-C46448FF136B", "DistrictRequestorID": nil, "VillageRequestorID": nil, "NyayPanchayatID": nil, "NyayPanchayat": nil, "GramPanchayatID": "", "GramPanchayat": "", "GrievenceTicketSourceTypeID": 132306, - "GrievenceSourceOtherType": nil, "TicketSubCategoryID": 10, "TicketCategoryID": 3, - "RequestYear": 2025, "RequestSeason": 1, "SocialMediaTypeID": nil, "OtherSocialMedia": nil, + "GrievenceSourceOtherType": nil, "TicketSubCategoryID": subCategory, "TicketCategoryID": category, + "RequestYear": number(lodge["requestYear"]), "RequestSeason": number(lodge["requestSeason"]), "SocialMediaTypeID": nil, "OtherSocialMedia": nil, "SocialMediaURL": nil, "ReceiptSourceID": 134306, "VillageName": nil, "CropName": app.crop, "TicketStatusID": 109301, "ApplicationNo": app.no, "InsuranceCompanyID": 1, "InsurancePolicyNo": app.no[:len(app.no)-2], "Email": nil, "SubDistrictID": nil, - "SubDistrictName": "Bhadra", "TicketSubCategoryName": "Portal Issues Login", "TicketStatus": "Open", - "InsuranceCompany": "AGRICULTURE INSURANCE COMPANY", "TicketCategoryName": "Enrollment", + "SubDistrictName": "Bhadra", "TicketSubCategoryName": nameOrNil(subCategoryNames, subCategory), "TicketStatus": "Open", + "InsuranceCompany": "AGRICULTURE INSURANCE COMPANY", "TicketCategoryName": nameOrNil(categoryNames, category), "StateMasterName": "RAJASTHAN", "DistrictMasterName": "Hanumangarh", - "GrievenceDescription": description, "IsLegal": nil, "LegalRemarks": nil, + "GrievenceDescription": lodge["grievenceDescription"], "IsLegal": nil, "LegalRemarks": nil, "CPGramRegistrationNumber": nil, "NCIPReferenceNumber": nil, "InsertUserID": 77777, - "InsertDateTime": time.Now().Format("2006-01-02 15:04:05"), "latestRemark": "", + "InsertDateTime": insertedAt.UTC().Format("2006-01-02T15:04:05.000Z"), "latestRemark": "", } } @@ -196,3 +211,16 @@ func ticketRef(t map[string]any) map[string]any { return map[string]any{"GrievenceSupportTicketNo": t["GrievenceSupportTicketNo"], "GrievenceSupportTicketID": t["GrievenceSupportTicketID"]} } + +// number reads a lodged value the way the portal stores it, as an integer. +func number(value any) int { + n, _ := strconv.Atoi(fmt.Sprint(value)) + return n +} + +func nameOrNil(names map[int]string, code int) any { + if name, known := names[code]; known { + return name + } + return nil +} From 33db96af9fd963c82b69dc696bb269142862fa50 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 17:03:42 +0530 Subject: [PATCH 6/7] feat: point the PMFBY example at Grievance and its mappings [OpenAgriNet/network-adapter#57] --- quick-start/.env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quick-start/.env.example b/quick-start/.env.example index b29ff74c..aeef3673 100644 --- a/quick-start/.env.example +++ b/quick-start/.env.example @@ -219,10 +219,10 @@ POCRA_MAPPING_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/0.0.1 # tokenHeader: the adapter logs in at TOKEN_URL and sends the token bare in # Authorization. BASE_URL is the host alone; the paths carry /krphapi/FGMS. PMFBY_PARTICIPANT_ID=pmfby -PMFBY_CAPABILITY=openagrinet:PMFBYGrievance +PMFBY_CAPABILITY=openagrinet:Grievance +# The directory holding grievance.support.yaml and grievance.status.yaml. +PMFBY_MAPPING_BASE_URL=https://raw.githubusercontent.com/OpenAgriNet/helmcharts/development/quick-start/config/mappings/pmfby PMFBY_BASE_URL= PMFBY_TOKEN_URL= PMFBY_ACCESS_UID= PMFBY_ACCESS_PWD= -# The directory holding grievance.support.yaml and grievance.status.yaml. -PMFBY_MAPPING_BASE_URL=https://raw.githubusercontent.com/OpenAgriNet/network-adapter/development/config/mappings/pmfby \ No newline at end of file From dca6014543042ebecbca5a04ce0c94fcf6d42542 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 23 Oct 2026 17:23:59 +0530 Subject: [PATCH 7/7] fix: match mock PMFBY status on ticket and phone; run it in make up [OpenAgriNet/network-adapter#57] A status read now needs the ticket's own number and phone, so a wrong ticket gets no record. mock-pmfby is built from source and started by make up. --- quick-start/bin/stack.sh | 12 ++++++------ quick-start/docker-compose.yml | 5 +++-- quick-start/mock-server/mockpmfby/main.go | 8 +++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/quick-start/bin/stack.sh b/quick-start/bin/stack.sh index 688d1494..e6d1877f 100755 --- a/quick-start/bin/stack.sh +++ b/quick-start/bin/stack.sh @@ -135,15 +135,15 @@ up_setup() { # Only now do the bind-mounted config files exist. up_adapters() { - # The mocks are NOT started. All five capabilities point at real external - # upstreams, so starting them would run containers nothing calls. They are - # still defined in docker-compose.yml, so `docker compose up -d mock-imd - # mock-agmarknet mock-pmfby` brings them up for anyone + # mock-pmfby is started with the adapters. The other mocks are NOT: those + # capabilities point at real external upstreams, so starting them would run + # containers nothing calls. They are still defined in docker-compose.yml, so + # `docker compose up -d mock-imd mock-agmarknet` brings them up for anyone # debugging a mapping without the real credentials -- but that also needs a # new participant id and base URL in .env plus a setup.py re-run, because # the registry cannot repoint an existing row. - step 3 "$1" "adapters (provider, network, consumer)" - docker compose up -d provider-adapter network-adapter consumer-adapter + step 3 "$1" "adapters (provider, network, consumer) and mock-pmfby" + docker compose up -d provider-adapter network-adapter consumer-adapter mock-pmfby } done_banner() { diff --git a/quick-start/docker-compose.yml b/quick-start/docker-compose.yml index 4d676033..b8796f79 100644 --- a/quick-start/docker-compose.yml +++ b/quick-start/docker-compose.yml @@ -587,10 +587,11 @@ services: - "127.0.0.1:${MOCKAGMARKNET_PORT}:9101" # PMFBY grievance (KRPH FGMS), copying the demo portal. Accepts the - # PMFBY_ACCESS_* login from .env. Built locally: - # docker build -t oan-mockpmfby:local mock-server/mockpmfby + # PMFBY_ACCESS_* login from .env. Built from mock-server/mockpmfby when the + # image is not already present. `make up` starts it. mock-pmfby: image: ${MOCKPMFBY_IMAGE} + build: ./mock-server/mockpmfby pull_policy: missing container_name: mock-pmfby restart: unless-stopped diff --git a/quick-start/mock-server/mockpmfby/main.go b/quick-start/mock-server/mockpmfby/main.go index 76d04aba..66936b9f 100644 --- a/quick-start/mock-server/mockpmfby/main.go +++ b/quick-start/mock-server/mockpmfby/main.go @@ -107,16 +107,14 @@ func main() { return envelope{ResponseCode: "1", ResponseMessage: "Ticket Raised Successfully", ResponseDynamic: ticketRef(t)} }) - // Matched on the phone alone: the demo ignores the ticket number unless it - // is absent, and with no phone answers its first record. + // A ticket is read only with its own number and the phone it was lodged from. handle("GetGrievenceTicketsStatus", *token, func(body map[string]any) envelope { - phone, hasPhone := body["requestorMobileNo"] - _, hasTicket := body["GrievenceSupportTicketNo"] + phone, ticketNo := fmt.Sprint(body["requestorMobileNo"]), fmt.Sprint(body["GrievenceSupportTicketNo"]) mu.Lock() defer mu.Unlock() for _, app := range applications { t, raised := tickets[app.no] - if raised && (!hasPhone || (hasTicket && fmt.Sprint(phone) == app.phone)) { + if raised && phone == app.phone && ticketNo == t["GrievenceSupportTicketNo"] { // A successful read is the one answer whose responseCode is a number. return envelope{ResponseCode: 1, ResponseMessage: "Fetched successfully", ResponseDynamic: t} }