Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Logs
.DS_Store
__MACOSX/
logs
.idea
*.log
Expand Down Expand Up @@ -176,4 +177,6 @@ test_request.json
.claude

# catalogPublish handler's local output root (config/local-beckn-one-bap.yaml)
/catalog/
/catalog/
/docs/
/dev_docs/
534 changes: 534 additions & 0 deletions config/mappings/pocra/agriculture-facility.select.yaml

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions config/provider-adapter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,33 @@ modules:
# something that no longer parses.
maxResponseBytes: 1048576

- id: AgricultureFacility
config:
# One capability for all four governed facility types. Which one a
# request wants is a value in its payload -- supportedFacilityTypes
# -- not part of the binding key, so there is one row in the
# registry and one mapping however many types are in use.
bindingKeys: "pocra|openagrinet:AgricultureFacility"

# POCRA's search takes no credential, which is also why its
# baseUrl can be published as it stands. Auth is per provider, in
# a block named for the participant id -- declared even when the
# scheme is none, because a served provider without a block is
# refused at startup.
pocra:
authScheme: none

# internal/common's default is 1 (sequential), deliberately:
# POCRA's failure mode when pushed is a 200 with an EMPTY catalog,
# indistinguishable from "no results", so a parallel search can
# silently lose a facility type with no error anywhere, with
# nothing in the payload recording that a type was asked for and
# lost. Kept at 1 here until that's verified against the live
# API -- raise it in a follow-up once confirmed safe, to at most
# 4, which is every governed type at once and so full
# concurrency for this capability.
searchConcurrency: 1

# Declaring a step above is not enough: THIS list is what runs. A step
# that appears under providerSteps but not here never executes, and the
# request falls through to the 404 above -- which looks like a registry
Expand All @@ -445,6 +472,7 @@ modules:
- WeatherObservation # its binding key, or pass through
- MandiPrice # its binding key, or pass through
- KnowledgeAdvisory # its binding key, or pass through
- AgricultureFacility # its binding key, or pass through
- signAck # signs whatever the step answered with

# ----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions install/build-plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ plugins=(
"WeatherObservation"
"MandiPrice"
"KnowledgeAdvisory"
"AgricultureFacility"
"manifestloader"
"reqpreprocessor"
"otelsetup"
Expand Down
112 changes: 112 additions & 0 deletions pkg/plugin/implementation/AgricultureFacility/AgricultureFacility.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Package AgricultureFacility serves the openagrinet:AgricultureFacility
// capability.
//
// One package per capability, named for the capability it serves, so which
// plugin owns one is readable from its binding key.
//
// Named for the capability and not for POCRA, deliberately. A provider is a
// registry row, and more than one could serve this same capability -- a second
// state aggregator would be another row and another mapping, not another
// package.
//
// Almost nothing lives here, and that is the point. Recognising a capability,
// resolving the call plan, authenticating, calling with the registry's budget
// and translating in both directions are all internal/upstream's, because none
// of them differ by domain. What this package owns is its name, and
// prerequisites -- the work a mapping cannot express, which is domain knowledge
// by definition.
//
// The upstream this was written against is POCRA's aggregator, whose search
// takes a category code and a point, both of which an AgricultureFacility
// payload carries. So the package is a name and nothing else: see
// prerequisites.go for why that is worth stating.
package AgricultureFacility

import (
"context"

"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common"
)

// Config carries everything common.Config does, plus this capability's own
// search concurrency -- which common.Config has no field for, because how
// many calls one payload becomes is not something that package knows about at
// all. A flat struct with common.Config's fields repeated rather than an
// alias (which this package used to be, and MandiPrice and WeatherObservation
// still are) or an embedded common.Config (which would break every existing
// flat struct literal, `&Config{BindingKeys: ..., AuthScheme: ...}`, since
// Go's composite literal syntax does not promote an embedded struct's fields
// the way a selector expression does).
type Config struct {
BindingKeys []string `yaml:"bindingKeys" json:"bindingKeys"`
ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"`
CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"`
MaxResponseBytes int64 `yaml:"maxResponseBytes" json:"maxResponseBytes"`

// AuthByProvider carries one credential profile per provider, keyed by
// participant id. Passed through to the inner step, which is where the
// schemes are defined and validated -- see common.AuthProfile.
AuthByProvider map[string]*common.AuthProfile `yaml:"-" json:"-"`

// FacilityTypesAt is where the payload carries the facility types a search
// asks for. Absent means DefaultFacilityTypesAt, which is the Beckn v2
// convention and what every deployment should be using -- the override is
// for tracking a spec change without waiting for a release, exactly as
// common.Config's providerIdAt and capabilityCodeAt are.
FacilityTypesAt string `yaml:"facilityTypesAt" json:"facilityTypesAt"`

// SearchConcurrency is how many of a multi-type search's calls may be in
// flight at once. See search.go's DefaultSearchConcurrency and
// MaxFacilityTypes for what absent and too-large mean.
SearchConcurrency int `yaml:"searchConcurrency" json:"searchConcurrency"`
}

// New creates the agriculture facility step.
//
// Which capabilities it answers to is configuration, with no default: a package
// serving a family cannot guess which of them a deployment has providers for.
//
// Two steps, one returned. The inner one is internal/upstream's, which serves
// one payload with one call and knows nothing about facility types. The outer
// one is this package's own (see search.go): it splits a multi-type search
// into one single-type payload per type, runs the inner step over each of them
// concurrently, and merges the answers. Everything POCRA-specific about that
// is in the outer step, which is why the inner one is the same step
// MandiPrice and WeatherObservation use unchanged.
func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper,
cfg *Config) (definition.Step, func() error, error) {
if cfg == nil {
cfg = &Config{}
}

upstreamCfg := &common.Config{
BindingKeys: cfg.BindingKeys,
ProviderIDAt: cfg.ProviderIDAt,
CapabilityCodeAt: cfg.CapabilityCodeAt,
MaxResponseBytes: cfg.MaxResponseBytes,
AuthByProvider: cfg.AuthByProvider,
}

one, closer, err := common.New(ctx, registry, mapper, prerequisites, upstreamCfg)
if err != nil {
return nil, nil, err
}

// The same paths the inner step resolved from the same config, so both
// answer "is this payload mine?" identically. Resolved through upstream
// rather than duplicated here: a second reading of the same two config
// fields could drift from the first.
paths, err := common.BindingPaths(upstreamCfg)
if err != nil {
return nil, nil, err
}

return &Step{
inner: one,
paths: paths,
bindingKeys: cfg.BindingKeys,
facilityTypesAt: cfg.FacilityTypesAt,
concurrency: searchConcurrency(cfg.SearchConcurrency),
}, closer, nil
}
187 changes: 187 additions & 0 deletions pkg/plugin/implementation/AgricultureFacility/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Agriculture Facility Plugin

A **provider step plugin** that serves `openagrinet:AgricultureFacility` by
calling an ordinary HTTP API that has never heard of Beckn.

Today that API is POCRA's aggregator, which answers
`pocra|openagrinet:AgricultureFacility` for the `select` action.

## What lives here

Almost nothing. Recognising a capability, resolving the call plan,
authenticating, calling with the registry's budget and translating in both
directions are all `internal/upstream`'s. This package owns its name and its
prerequisites — which are empty, because a facility search names the point and
the facility type it wants, and POCRA's search takes exactly those.

Named for the schema-pack family rather than for POCRA: a provider is a registry
row, and a second state aggregator would be another row and another mapping, not
another package.

## Configuration

```yaml
providerSteps:
- id: AgricultureFacility
config:
bindingKeys: "pocra|openagrinet:AgricultureFacility"
authScheme: none
searchConcurrency: 4
```

## How a multi-type search is served

POCRA's search takes exactly ONE category code: a comma-separated pair answers
200 with no providers at all, and a category array is refused outright, both
verified against the live API. A Beckn payload asking for three facility types
therefore has to become three calls.

That is this package's own job, and all of it lives here:

- `search.go` is the step the adapter runs. It reads the facility types out of
the payload, splits one payload into one single-type payload per type -- each
with a fresh `context.messageId`, because POCRA returns the union of
everything asked for under one id -- runs the ordinary upstream step over
each part concurrently, and merges the answers into one.
- `payload.go` is the one path read this package does in Go rather than in a
mapping, and the one place `supportedFacilityTypes` is located. Its own
comment says what that costs.
- `internal/upstream` serves one payload with one call and knows none of this.
`jsonmapper` compiles the two halves every mapping has and no third thing.
`internal/concurrent` runs N of anything, bounded and ordered, and has never
heard of Beckn.

**Ordering, worth knowing:** within one facility type the mapping ranks by
POCRA's distance, and that ranking survives the merge. ACROSS types the answer
is type-blocked -- every KrishiVigyanKendra, then every Warehouse -- rather
than globally nearest-first, because the schema pack says query-relative
distance is not a facility attribute, so the mapping drops it before the merge
could sort on it.

| Parameter | Required | Description | Default |
|-----------|----------|-------------|---------|
| `bindingKeys` | **Yes** | Comma-separated capabilities this step answers to. No default is possible: a package serving a family cannot guess which of them a deployment has providers for. | — |
| `providerIdAt` | No | Path override for where the provider-id half of a binding key sits in a payload. Beckn v2 convention if absent. | Beckn v2 convention |
| `capabilityCodeAt` | No | Path override for the capability-code half. Must be given together with `providerIdAt`. | Beckn v2 convention |
| `authScheme` | No | `none`, `basic`, `header` or `query`. POCRA needs none. | `none` |
| `maxResponseBytes` | No | Cap on what is read from the provider. | 4 MiB |
| `searchConcurrency` | No | How many of a multi-type search's calls run at once, up to `MaxFacilityTypes` (8, this package's own constant -- see `search.go`). 4 is every governed type at once -- full concurrency for this capability. **Trade-off:** defaults to 1 (sequential) because POCRA's failure mode when pushed is a 200 with an *empty* catalog, indistinguishable from "no results" -- a parallel search can silently drop a facility type with no error. Verify against the live API before raising it in production. | 1 (sequential) |

This package's `Config` (`AgricultureFacility.go`, a flat struct of its own because it carries `searchConcurrency`, which `upstream.Config` has no field for) also has the `basic`/`header`/`query` auth credential pairs (`usernameEnv`/`passwordEnv`, `headerName`/`headerValueEnv`, `queryName`/`queryValueEnv`). This plugin's `parseConfig` does not wire them through -- POCRA needs none of them. A second provider on `openagrinet:AgricultureFacility` (see "What lives here") that needs one adds the corresponding line to `parseConfig`, mirroring `maxResponseBytes`.

The id must also appear in the module's `steps:` list, and must be unique across
`steps` and `providerSteps` — a repeat is refused at startup, because both land
in one id-keyed map and one capability would otherwise be lost silently.

## Registry rows

Two, joined on `participantId`.

```json
{ "participantId": "pocra", "name": "PoCRA Provider Aggregator",
"type": "upstream", "status": "active",
"baseUrl": "https://middleware-bap-client.mahapocra.gov.in" }
```
```json
{ "bindingKey": "pocra|openagrinet:AgricultureFacility",
"participantId": "pocra",
"capabilityCode": "openagrinet:AgricultureFacility",
"status": "active",
"actions": [ { "action": "select", "method": "POST", "path": "/search",
"mappings": "<published>/agriculture-facility.select.yaml",
"timeoutMs": 30000, "retryMax": 2, "status": "active" } ] }
```

`retryMax` is 2. The step marks a 4xx other than 429 as permanent and stops
retrying it, so a schema NACK caused by our own malformed request costs one
attempt rather than three. What the budget buys is resilience against a 5xx, a
429 or a transport failure, backing off exponentially from 50ms.

## Facility types

The governed enum maps one-to-one onto POCRA's category codes. The translation
lives in the mapping and nowhere else.

| `FacilityType` | POCRA code |
|---|---|
| `CustomHiringCentre` | `chc` |
| `KrishiVigyanKendra` | `kvk` |
| `Warehouse` | `warehouse` |
| `SoilTestingFacility` | `soil_lab` |

Adding a type is three lines in
`config/mappings/pocra/agriculture-facility.select.yaml` — the forward table in
the request half, the inverse in the response half, and the governed value in the
precondition's list. No rebuild.

## Where the query lives

An inbound query resource is `informationMode: OnDemand`. The search origin is
read from `message.contract.commitments[].fulfillment.stops[].location.geo`
and the requested type from `resourceAttributes.supportedFacilityTypes`,
rather than from `location`/`address`/`facilityType` on `resourceAttributes` --
a convention this plugin keeps, not a schema requirement. The pack forbade
those fields under OnDemand until `network-specs` commit `b76c9ad8a5` on
`schema-packs-v0.1` dropped that constraint (see
`dev_docs/schema-onDemand-forbid-removed.md`); the plugin's behavior did not
change when that happened, since POCRA has no verified per-facility
coordinate to put there anyway.

**This convention is provisional.** It was chosen on design and has not been
confirmed against a payload captured from the network.

## What the answer deliberately omits

`location`, because POCRA returns no verified per-facility coordinate — the one
`gps` in its response is a fixed stub unrelated to the point that was asked for —
and the pack forbids substituting the search origin.

`services`, `capacity`, `website` and `lastUpdatedAt`, because POCRA supplies
none of them. Deriving services from the facility type, or `lastUpdatedAt` from
the time of the fetch, would assert something nobody verified.

Distance, which POCRA does supply, is used to order the resources nearest-first
and then dropped: the pack states that query-relative distance is not an
intrinsic facility attribute and belongs in result metadata.

`"Unknown"`, `"N/A"`, `"000000"` and `"-"` are treated as absent wherever POCRA
sends them, so a field is omitted rather than published as a placeholder. The
warehouse BPP uses `"-"` for a phone it does not have; the other three use
`"N/A"`.

`@context` is echoed from the request rather than restated here, so this mapping
does not have to know which pack identifier is current and cannot contradict
what the caller declared. Which matters: the identifier the packs name in their
own `x-jsonld`, `https://schemas.openagrinet.global/…`, has no DNS record, so a
deployment tracking the published ref sends the `raw.githubusercontent.com` pack
URL instead. Both are exercised.

## Testing

```sh
go test ./pkg/plugin/implementation/AgricultureFacility/...
```

25 pass, 2 skip. The two skips are live tests against the real POCRA API, opted
into with `POCRA_LIVE=1`.

`mappings_test.go` runs the shipped mapping through the real mapper and the real
step against a fake POCRA. It reads from `config/mappings/pocra/` rather than
from a fixture, so it breaks when what is deployed breaks.

`conformance_test.go` validates the answer against
`openagrinet:AgricultureFacility v0.1` with a real JSON Schema validator. It
also validates the pack's own published examples — if those fail, the
compilation is wrong and nothing else in that file means anything — and covers
the two things the schema is silent on: fields the pack does not declare, and
the README's prose mapping rules.

The schemas are not vendored. `schemacache_test.go` compiles the pack under the
URL that publishes it,

https://github.com/OpenAgriNet/network-specs/tree/schema-packs-v0.1/schema/AgricultureFacility/v0.1

lets the validator resolve the pack's own `$ref`s, and caches each document
under `testdata/schema-cache/` (gitignored). A cold run needs the network; every
run after it is offline. With an empty cache **and** no network the schema tests
skip rather than fail. To refresh, delete the cache directory and re-run.
Loading
Loading