diff --git a/config/mappings/agmarknet/mandi-price.select.yaml b/config/mappings/agmarknet/mandi-price.select.yaml index d10a0ce8..81e11115 100644 --- a/config/mappings/agmarknet/mandi-price.select.yaml +++ b/config/mappings/agmarknet/mandi-price.select.yaml @@ -57,8 +57,9 @@ required: this guard, the outbound query, and the commodity stamped on each resource. So a caller sending three commodities passed validation, was queried for the first, and got a confident signed answer to a third of - what it asked -- the same failure oanbinding refuses at the commitment - level, where guessing would silently serve part of a request. */ + what it asked -- the same failure capabilitybinding refuses at the + commitment level, where guessing would silently serve part of a + request. */ $exists($ra.supportedCommodities[0].code) and $count($ra.supportedCommodities) = 1 ) diff --git a/config/oan-provider-adapter.yaml b/config/provider-adapter.yaml similarity index 78% rename from config/oan-provider-adapter.yaml rename to config/provider-adapter.yaml index 34730d21..b2329ab0 100644 --- a/config/oan-provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -1,4 +1,4 @@ -# OAN provider adapter. +# Provider adapter. # # Serves the Beckn actions synchronously: verifies the sender, resolves the # capability's call plan from the registry, calls the provider, and answers with @@ -19,7 +19,7 @@ # serves. It does NOT hold where a provider lives or how long to wait for it -- # that is the registry's ProviderSchema row, read per request. Repointing a # provider is a registry write, not an edit here and a restart. -appName: "oan-provider-adapter" +appName: "provider-adapter" log: level: debug @@ -80,7 +80,7 @@ modules: plugins: # ------------------------------------------------------------------ - # oanregistry -- the OAN Registry (SunbirdRC) client. + # sunbirdRegistry -- the SunbirdRC registry client. # # Serves both halves of the lookup: the sender's signing key for # validateSign, and the capability call plans the provider steps @@ -89,7 +89,7 @@ modules: # other. # ------------------------------------------------------------------ registry: - id: oanregistry + id: sunbirdRegistry config: # REQUIRED, and the only key with no default. Include the API # version prefix; the plugin appends /{entity}/search. @@ -154,27 +154,92 @@ modules: signValidator: id: signvalidator - # Base Beckn v2 schema validation against the pinned LTS spec. The - # extended layer fetches each resource's own @context and validates - # against that -- a network call per payload and a second thing that - # can fail -- so it is off, and the extendedSchema_* keys below only - # take effect if it is switched on. + # ------------------------------------------------------------------ + # schemaValidator -- two layers, both on. + # + # BASE validates the envelope against the pinned Beckn v2 LTS spec. + # To it, resourceAttributes is a free-form object: the envelope is + # correct whatever a capability puts inside. + # + # EXTENDED validates that inside. It walks the payload for every + # object carrying both @context and @type, resolves the schema that + # @type names, and validates the object against it. That is what makes + # a wrong unit or a missing required attribute a rejected payload + # rather than a provider's problem to discover later. + # + # The schemas are not in this repository and are not mounted: they + # are fetched from the @context a payload declares, so the revision + # is the payload's choice and nothing here can go stale against it. + # + # What extended validation DOES enforce: types, string formats + # (date-time, duration, uri), enum, const, required, minItems, + # additionalProperties, not, and allOf/anyOf/oneOf. + # + # What it does NOT: if/then/else. The validator library parses those + # keywords but never evaluates them, so a pack's conditional rules -- + # in the capability packs, everything predicated on informationMode + # -- are not checked. Worth knowing before treating a pass here as + # full conformance to a pack. + # ------------------------------------------------------------------ schemaValidator: id: schemav2validator config: type: url location: "https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml" cacheTTL: "3600" - extendedSchema_enabled: "false" - extendedSchema_cacheTTL: "86400" + + extendedSchema_enabled: "true" + + # Resolution is a FETCH of the @context each resource declares, + # not a directory mounted here. The validator swaps context.jsonld + # for attributes.yaml to get the schema beside it: + # + # @context .../schema/MandiPrice/v0.1/context.jsonld + # fetched .../schema/MandiPrice/v0.1/attributes.yaml + # + # So a payload names the pack revision it wants to be judged + # against, and no copy of the schemas here can drift from the + # published ones. The packs' relative $refs (into + # AgricultureResource) resolve against that same base; their + # absolute ones resolve directly, against whichever host they name. + # + # Fetched once per @context and cached for the TTL below, so only + # the first payload after a restart pays for it. A fetch that + # FAILS rejects the payload -- it does not skip validation, which + # is the right way round, but it does mean this adapter needs + # egress to every host allowed below. + # + # THIS IS THE WHOLE TRUST BOUNDARY, and it is checked on every + # read: the entry @context and every $ref under it. That matters + # because the document a payload names is NOT trusted -- it comes + # from a URL the payload chose, on a host anyone can publish to -- + # so without the check its $refs could send this process at an + # internal service or a cloud metadata endpoint. + # + # ALL THREE HOSTS ARE REQUIRED. Loading one capability pack pulls + # 13-16 documents across exactly these three (measured, not + # assumed): the pack itself and AgricultureResource from the raw + # CDN, then Descriptor/GeoJSONGeometry/Location/Address from + # schema.beckn.io, which in turn $ref schema.nfh.global. Remove + # any one and no pack loads at all -- the failure is + # SCH_SCHEMA_ADAPTATION_FAILED on every payload, not a partial + # validation. + # + # Keep it as tight as the packs allow. raw.githubusercontent.com + # is world-writable, so this trusts every GitHub account for + # schema content; narrowing it to a path prefix, or mirroring the + # packs on a host we control, is the real fix and is not a + # one-line change. + extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global" + + extendedSchema_cacheTTL: "86400" # 24h extendedSchema_maxCacheSize: "100" extendedSchema_downloadTimeout: "30" - extendedSchema_allowedDomains: "raw.githubusercontent.com" # ------------------------------------------------------------------ # jsonmapper -- the JSONata mapper. # - # Generic, and named for what it is rather than for OAN: it knows + # Generic, and named for what it is rather than for a network: it knows # nothing about any provider. It fetches whatever URL the registry's # mappings field names, compiles the JSONata, caches the compiled # form, and runs it in both directions. @@ -228,7 +293,7 @@ modules: # rather than calling the provider unauthenticated. # ------------------------------------------------------------------ providerSteps: - - id: weather + - id: WeatherObservation config: # REQUIRED. Comma-separated, because a plugin config value is a # string and one provider may serve several capabilities. A @@ -278,9 +343,9 @@ modules: # A second capability in the same pipeline, from a different domain # package. Nothing about it is weather's business: a different # upstream, a different mapping, a different set of prerequisites -- - # and the same two registry rows. This entry, plus "mandi" in steps - # below, is the entire cost of adding it. - - id: mandi + # and the same two registry rows. This entry, plus "MandiPrice" + # in steps below, is the entire cost of adding it. + - id: MandiPrice config: bindingKeys: "agmarknet|openagrinet:MandiPrice" @@ -319,8 +384,8 @@ modules: steps: - validateSign # the sender's key, from the registry - validateSchema # the pinned Beckn v2 spec - - weather # openagrinet:WeatherObservation, or pass through - - mandi # openagrinet:MandiPrice, or pass through + - WeatherObservation # its binding key, or pass through + - MandiPrice # its binding key, or pass through - signAck # signs whatever the step answered with # ---------------------------------------------------------------------------- diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 8ac0d713..b7e64256 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -30,10 +30,10 @@ plugins=( "publisher" "registry" "dediregistry" - "oanregistry" + "sunbirdRegistry" "jsonmapper" - "weather" - "mandi" + "WeatherObservation" + "MandiPrice" "manifestloader" "reqpreprocessor" "otelsetup" diff --git a/pkg/plugin/definition/mapper.go b/pkg/plugin/definition/mapper.go index e5c7be95..e1a3790e 100644 --- a/pkg/plugin/definition/mapper.go +++ b/pkg/plugin/definition/mapper.go @@ -17,7 +17,7 @@ const ( // Mapper transforms a document with a mapping fetched from a reference. // -// It exists so that translating between OAN's Beckn payloads and a provider's +// It exists so that translating between the network's Beckn payloads and a provider's // own shape is configuration rather than code: a new provider ships mapping // files, not a new transformation routine. The mapper itself knows nothing // about any provider, and nothing about what a mapping says -- it fetches, diff --git a/pkg/plugin/implementation/mandi/mandi.go b/pkg/plugin/implementation/MandiPrice/MandiPrice.go similarity index 81% rename from pkg/plugin/implementation/mandi/mandi.go rename to pkg/plugin/implementation/MandiPrice/MandiPrice.go index 26e8b94b..38fff611 100644 --- a/pkg/plugin/implementation/mandi/mandi.go +++ b/pkg/plugin/implementation/MandiPrice/MandiPrice.go @@ -1,8 +1,8 @@ -// Package mandi serves the network's market price capabilities. +// Package MandiPrice serves the network's market price capabilities. // -// One package per schema pack family, so which plugin owns a capability is -// readable from its binding key: openagrinet:MandiPrice is mandi's, -// openagrinet:WeatherObservation is weather's. +// One package per capability, named for the capability it serves, so which +// plugin owns a payload is readable from its binding key without a lookup: +// openagrinet:MandiPrice is this one's, openagrinet:WeatherObservation is not. // // Almost nothing lives here, and that is the point. Recognising a capability, // resolving the call plan, authenticating, calling with the registry's budget @@ -15,7 +15,7 @@ // select takes governed codes for state, district, market and commodity plus a // date range, all of which a MandiPrice payload carries. So the package is a // name and nothing else: see prerequisites.go for why that is worth stating. -package mandi +package MandiPrice import ( "context" diff --git a/pkg/plugin/implementation/mandi/cmd/plugin.go b/pkg/plugin/implementation/MandiPrice/cmd/plugin.go similarity index 91% rename from pkg/plugin/implementation/mandi/cmd/plugin.go rename to pkg/plugin/implementation/MandiPrice/cmd/plugin.go index 5b4fbb70..8dcce23f 100644 --- a/pkg/plugin/implementation/mandi/cmd/plugin.go +++ b/pkg/plugin/implementation/MandiPrice/cmd/plugin.go @@ -13,20 +13,20 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" ) // mandiProvider implements definition.ProviderStepProvider. type mandiProvider struct{} // newStepFunc creates a new step. Indirected for tests. -var newStepFunc = mandi.New +var newStepFunc = MandiPrice.New // parseConfig turns the plugin config map into a typed Config. Anything absent -// is left zero: mandi.New applies the defaults and validates the auth scheme, +// is left zero: MandiPrice.New applies the defaults and validates the auth scheme, // so those rules live in one place. -func (p mandiProvider) parseConfig(config map[string]string) (*mandi.Config, error) { - cfg := &mandi.Config{ +func (p mandiProvider) parseConfig(config map[string]string) (*MandiPrice.Config, error) { + cfg := &MandiPrice.Config{ BindingKeys: splitList(config["bindingKeys"]), // Absent means the Beckn v2 convention. See upstream.Config for why // this is a default rather than something to set. diff --git a/pkg/plugin/implementation/mandi/cmd/plugin_test.go b/pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go similarity index 95% rename from pkg/plugin/implementation/mandi/cmd/plugin_test.go rename to pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go index 262f73a6..0a3c1a50 100644 --- a/pkg/plugin/implementation/mandi/cmd/plugin_test.go +++ b/pkg/plugin/implementation/MandiPrice/cmd/plugin_test.go @@ -9,7 +9,7 @@ import ( "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" ) type stubRegistry struct{} @@ -32,15 +32,15 @@ func TestParseConfig(t *testing.T) { testCases := []struct { name string config map[string]string - expected *mandi.Config + expected *MandiPrice.Config expectedErr string }{ { - // Everything absent is left zero: mandi.New defaults it, so the + // Everything absent is left zero: MandiPrice.New defaults it, so the // rules are defined in exactly one place. name: "leaves everything unset for New to default", config: map[string]string{}, - expected: &mandi.Config{}, + expected: &MandiPrice.Config{}, }, { // Query auth is why this capability has its own entry rather than @@ -54,7 +54,7 @@ func TestParseConfig(t *testing.T) { "queryName": "api-key", "queryValueEnv": "MANDI_TOKEN", }, - expected: &mandi.Config{ + expected: &MandiPrice.Config{ BindingKeys: []string{"agmarknet|openagrinet:MandiPrice"}, AuthScheme: "query", QueryName: "api-key", @@ -74,7 +74,7 @@ func TestParseConfig(t *testing.T) { "queryValueEnv": "Q", "maxResponseBytes": "2048", }, - expected: &mandi.Config{ + expected: &MandiPrice.Config{ BindingKeys: []string{"other|capability"}, AuthScheme: "basic", UsernameEnv: "U", @@ -102,7 +102,7 @@ func TestParseConfig(t *testing.T) { // as "unset" rather than failing startup. name: "treats an empty response cap as unset", config: map[string]string{"maxResponseBytes": ""}, - expected: &mandi.Config{}, + expected: &MandiPrice.Config{}, }, } @@ -243,7 +243,7 @@ func TestNew(t *testing.T) { closed := false original := newStepFunc newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, - *mandi.Config) (definition.Step, func() error, error) { + *MandiPrice.Config) (definition.Step, func() error, error) { return nil, func() error { closed = true; return nil }, nil } defer func() { newStepFunc = original }() @@ -268,7 +268,7 @@ func TestNew(t *testing.T) { original := newStepFunc wanted := errors.New("upstream refused the config") newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, - *mandi.Config) (definition.Step, func() error, error) { + *MandiPrice.Config) (definition.Step, func() error, error) { return nil, nil, wanted } defer func() { newStepFunc = original }() diff --git a/pkg/plugin/implementation/mandi/mappings_test.go b/pkg/plugin/implementation/MandiPrice/mappings_test.go similarity index 98% rename from pkg/plugin/implementation/mandi/mappings_test.go rename to pkg/plugin/implementation/MandiPrice/mappings_test.go index 6be53f46..ef91a40c 100644 --- a/pkg/plugin/implementation/mandi/mappings_test.go +++ b/pkg/plugin/implementation/MandiPrice/mappings_test.go @@ -1,4 +1,4 @@ -package mandi_test +package MandiPrice_test // mappings_test.go runs the shipped mandi mapping through the real mapper and // the real provider step. It is the only test that proves the three pieces fit: @@ -22,8 +22,8 @@ import ( "testing" "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi" ) // mappingsDir is where the shipped mappings live, relative to this package. @@ -191,8 +191,8 @@ func runShippedWith(t *testing.T, request, providerBody string) (url.Values, map }, }} - step, closeStep, err := mandi.New(context.Background(), registry, mapper, - &mandi.Config{BindingKeys: []string{shippedBindingKey}}) + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) if err != nil { t.Fatalf("failed to build the step: %v", err) } @@ -431,8 +431,8 @@ func TestShippedMappingRefusesWhatItCannotServe(t *testing.T) { Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000}, }, }} - step, closeStep, err := mandi.New(context.Background(), registry, mapper, - &mandi.Config{BindingKeys: []string{shippedBindingKey}}) + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) if err != nil { t.Fatalf("failed to build the step: %v", err) } @@ -740,8 +740,8 @@ func TestShippedMappingRefusesPayloadsItCannotAnswer(t *testing.T) { Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000}, }, }} - step, closeStep, err := mandi.New(context.Background(), registry, mapper, - &mandi.Config{BindingKeys: []string{shippedBindingKey}}) + step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper, + &MandiPrice.Config{BindingKeys: []string{shippedBindingKey}}) if err != nil { t.Fatalf("failed to build the step: %v", err) } diff --git a/pkg/plugin/implementation/mandi/prerequisites.go b/pkg/plugin/implementation/MandiPrice/prerequisites.go similarity index 89% rename from pkg/plugin/implementation/mandi/prerequisites.go rename to pkg/plugin/implementation/MandiPrice/prerequisites.go index 2027248b..d7c1f8aa 100644 --- a/pkg/plugin/implementation/mandi/prerequisites.go +++ b/pkg/plugin/implementation/MandiPrice/prerequisites.go @@ -1,4 +1,4 @@ -package mandi +package MandiPrice import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" @@ -15,6 +15,6 @@ import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstr // code, a token to exchange, a point to turn into a market. Each of those is a // different upstream than the one this was written against, and each would // bring the question of where the provider-to-function binding belongs -- see -// the note in weather/prerequisites.go and prefer keeping the payload explicit +// the note in WeatherObservation/prerequisites.go and prefer keeping the payload explicit // over adding an entry here. var prerequisites = upstream.Prerequisites{} diff --git a/pkg/plugin/implementation/weather/weather.go b/pkg/plugin/implementation/WeatherObservation/WeatherObservation.go similarity index 68% rename from pkg/plugin/implementation/weather/weather.go rename to pkg/plugin/implementation/WeatherObservation/WeatherObservation.go index b899e45b..65820e84 100644 --- a/pkg/plugin/implementation/weather/weather.go +++ b/pkg/plugin/implementation/WeatherObservation/WeatherObservation.go @@ -1,15 +1,18 @@ -// Package weather serves the network's weather capabilities. +// Package WeatherObservation serves the network's weather capabilities. // -// One package per schema pack family, so which plugin owns a capability is -// readable from its binding key: openagrinet:WeatherObservation and -// openagrinet:WeatherAdvisory are weather's, openagrinet:MandiPrice is not. +// One package per capability, named for the capability it serves, so which +// plugin owns a payload is readable from its binding key without a lookup: +// openagrinet:WeatherObservation is this one's, openagrinet:MandiPrice is not. +// Which keys it answers to is still configuration -- a deployment can point it +// at a related pack such as openagrinet:WeatherAdvisory -- but the name says +// what it was built against. // // Almost nothing lives here. 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. -package weather +package WeatherObservation import ( "context" diff --git a/pkg/plugin/implementation/weather/cmd/plugin.go b/pkg/plugin/implementation/WeatherObservation/cmd/plugin.go similarity index 91% rename from pkg/plugin/implementation/weather/cmd/plugin.go rename to pkg/plugin/implementation/WeatherObservation/cmd/plugin.go index e78c17ef..9a53bf97 100644 --- a/pkg/plugin/implementation/weather/cmd/plugin.go +++ b/pkg/plugin/implementation/WeatherObservation/cmd/plugin.go @@ -9,20 +9,20 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/weather" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" ) // weatherProvider implements definition.ProviderStepProvider. type weatherProvider struct{} // newStepFunc creates a new step. Indirected for tests. -var newStepFunc = weather.New +var newStepFunc = WeatherObservation.New // parseConfig turns the plugin config map into a typed Config. Anything absent -// is left zero: weather.New applies the defaults and validates the auth +// is left zero: WeatherObservation.New applies the defaults and validates the auth // scheme, so those rules live in one place. -func (p weatherProvider) parseConfig(config map[string]string) (*weather.Config, error) { - cfg := &weather.Config{ +func (p weatherProvider) parseConfig(config map[string]string) (*WeatherObservation.Config, error) { + cfg := &WeatherObservation.Config{ BindingKeys: splitList(config["bindingKeys"]), // Absent means the Beckn v2 convention. See upstream.Config for why // this is a default rather than something to set. diff --git a/pkg/plugin/implementation/weather/cmd/plugin_test.go b/pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go similarity index 94% rename from pkg/plugin/implementation/weather/cmd/plugin_test.go rename to pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go index e04b9191..f95323da 100644 --- a/pkg/plugin/implementation/weather/cmd/plugin_test.go +++ b/pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go @@ -9,7 +9,7 @@ import ( "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/weather" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" ) type stubRegistry struct{} @@ -32,15 +32,15 @@ func TestParseConfig(t *testing.T) { testCases := []struct { name string config map[string]string - expected *weather.Config + expected *WeatherObservation.Config expectedErr string }{ { - // Everything absent is left zero: weather.New defaults it, so the + // Everything absent is left zero: WeatherObservation.New defaults it, so the // rules are defined in exactly one place. name: "leaves everything unset for New to default", config: map[string]string{}, - expected: &weather.Config{}, + expected: &WeatherObservation.Config{}, }, { name: "reads every supported setting", @@ -53,7 +53,7 @@ func TestParseConfig(t *testing.T) { "headerValueEnv": "V", "maxResponseBytes": "2048", }, - expected: &weather.Config{ + expected: &WeatherObservation.Config{ BindingKeys: []string{"other|capability"}, AuthScheme: "basic", UsernameEnv: "U", @@ -222,7 +222,7 @@ func TestNew(t *testing.T) { t.Cleanup(func() { newStepFunc = original }) wantErr := errors.New("boom") - newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, *weather.Config) (definition.Step, func() error, error) { + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, *WeatherObservation.Config) (definition.Step, func() error, error) { return nil, nil, wantErr } diff --git a/pkg/plugin/implementation/weather/mappings_test.go b/pkg/plugin/implementation/WeatherObservation/mappings_test.go similarity index 98% rename from pkg/plugin/implementation/weather/mappings_test.go rename to pkg/plugin/implementation/WeatherObservation/mappings_test.go index 4c275765..d8688193 100644 --- a/pkg/plugin/implementation/weather/mappings_test.go +++ b/pkg/plugin/implementation/WeatherObservation/mappings_test.go @@ -1,4 +1,4 @@ -package weather_test +package WeatherObservation_test // mappings_test.go runs the shipped mapping files through the real mapper and // the real provider step. It is the only test that proves the three pieces fit: @@ -22,8 +22,8 @@ import ( "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/WeatherObservation" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/weather" ) // mappingsDir is where the shipped mappings live, relative to this package. @@ -43,7 +43,7 @@ const shippedBindingKey = "mausamgram|openagrinet:WeatherObservation" const shippedMapping = "weather-observation.select.yaml" -// selectRequest is the verbatim /select captured from the OAN network. +// selectRequest is the verbatim /select captured from the network. const selectRequest = `{ "context": { "version": "2.0.0", "action": "select", "networkId": "da.gov.in/vistaar", @@ -142,8 +142,8 @@ func TestShippedMappingsServeARealSelect(t *testing.T) { }, }} - step, closeStep, err := weather.New(context.Background(), registry, mapper, - &weather.Config{BindingKeys: []string{shippedBindingKey}}) + step, closeStep, err := WeatherObservation.New(context.Background(), registry, mapper, + &WeatherObservation.Config{BindingKeys: []string{shippedBindingKey}}) if err != nil { t.Fatalf("failed to build the step: %v", err) } diff --git a/pkg/plugin/implementation/weather/prerequisites.go b/pkg/plugin/implementation/WeatherObservation/prerequisites.go similarity index 97% rename from pkg/plugin/implementation/weather/prerequisites.go rename to pkg/plugin/implementation/WeatherObservation/prerequisites.go index 0c7b947a..3492d577 100644 --- a/pkg/plugin/implementation/weather/prerequisites.go +++ b/pkg/plugin/implementation/WeatherObservation/prerequisites.go @@ -1,4 +1,4 @@ -package weather +package WeatherObservation import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream" diff --git a/pkg/plugin/implementation/internal/oanbinding/oanbinding.go b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go similarity index 84% rename from pkg/plugin/implementation/internal/oanbinding/oanbinding.go rename to pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go index b53290ef..e72b1dd7 100644 --- a/pkg/plugin/implementation/internal/oanbinding/oanbinding.go +++ b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go @@ -1,10 +1,10 @@ -// Package oanbinding derives the capability binding a Beckn request is asking +// Package capabilitybinding derives the capability binding a Beckn request is asking // for, so a provider step can tell whether the request is its work and, if it // is, which registry row describes the call. // // It is shared by every provider step rather than living in one, because the -// binding is a property of the OAN network's payloads and not of any provider. -package oanbinding +// binding is a property of the network's payloads and not of any provider. +package capabilitybinding import ( "encoding/json" @@ -19,7 +19,7 @@ const separator = "|" // ErrNoBinding reports a payload that names no capability binding. It is not a // fault: a request for something else entirely reaches a provider step too, and // the step's answer is to do nothing. -var ErrNoBinding = errors.New("oanbinding: payload names no capability binding") +var ErrNoBinding = errors.New("capabilitybinding: payload names no capability binding") // Binding identifies one provider capability. type Binding struct { @@ -47,7 +47,7 @@ func (b Binding) Key() string { func From(paths Paths, body []byte) (Binding, error) { var payload any if err := json.Unmarshal(body, &payload); err != nil { - return Binding{}, fmt.Errorf("oanbinding: payload could not be read: %w", err) + return Binding{}, fmt.Errorf("capabilitybinding: payload could not be read: %w", err) } // Before distinctness: N commitments naming the SAME provider and type @@ -64,7 +64,7 @@ func From(paths Paths, body []byte) (Binding, error) { // the paragraph above says is refused. if commitments := countAt(payload, paths.ProviderID); commitments > 1 { return Binding{}, fmt.Errorf( - "oanbinding: payload carries %d commitments; one request maps to one call, "+ + "capabilitybinding: payload carries %d commitments; one request maps to one call, "+ "so send them separately rather than have all but the first dropped", commitments) } @@ -76,11 +76,11 @@ func From(paths Paths, body []byte) (Binding, error) { return Binding{}, ErrNoBinding } if len(providers) > 1 { - return Binding{}, fmt.Errorf("oanbinding: payload names %d providers (%s); one request maps to one call", + return Binding{}, fmt.Errorf("capabilitybinding: payload names %d providers (%s); one request maps to one call", len(providers), strings.Join(providers, ", ")) } if len(types) > 1 { - return Binding{}, fmt.Errorf("oanbinding: payload names %d resource types (%s); one request maps to one call", + return Binding{}, fmt.Errorf("capabilitybinding: payload names %d resource types (%s); one request maps to one call", len(types), strings.Join(types, ", ")) } diff --git a/pkg/plugin/implementation/internal/oanbinding/oanbinding_test.go b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go similarity index 99% rename from pkg/plugin/implementation/internal/oanbinding/oanbinding_test.go rename to pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go index 1a9d0836..43213053 100644 --- a/pkg/plugin/implementation/internal/oanbinding/oanbinding_test.go +++ b/pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go @@ -1,4 +1,4 @@ -package oanbinding +package capabilitybinding import ( "encoding/json" @@ -7,7 +7,7 @@ import ( "testing" ) -// realSelectPayload is a verbatim /select request captured from the OAN network +// realSelectPayload is a verbatim /select request captured from the network // on 29 Aug 2026. It is the reason this package reads through contract and // commitments rather than off message directly: the design notes showed the // shallower message.offer.provider.id, and the wire does not. diff --git a/pkg/plugin/implementation/internal/oanbinding/paths.go b/pkg/plugin/implementation/internal/capabilitybinding/paths.go similarity index 95% rename from pkg/plugin/implementation/internal/oanbinding/paths.go rename to pkg/plugin/implementation/internal/capabilitybinding/paths.go index 5c47deb2..f1f89a2a 100644 --- a/pkg/plugin/implementation/internal/oanbinding/paths.go +++ b/pkg/plugin/implementation/internal/capabilitybinding/paths.go @@ -1,4 +1,4 @@ -package oanbinding +package capabilitybinding import ( "fmt" @@ -38,11 +38,11 @@ func (p Paths) Validate() error { "capabilityCodeAt": p.CapabilityCode, } { if strings.TrimSpace(path) == "" { - return fmt.Errorf("oanbinding: %s is empty", name) + return fmt.Errorf("capabilitybinding: %s is empty", name) } for _, segment := range strings.Split(path, ".") { if strings.TrimSpace(strings.TrimSuffix(segment, arrayMarker)) == "" { - return fmt.Errorf("oanbinding: %s (%q) has a blank segment", name, path) + return fmt.Errorf("capabilitybinding: %s (%q) has a blank segment", name, path) } } } diff --git a/pkg/plugin/implementation/internal/upstream/upstream.go b/pkg/plugin/implementation/internal/upstream/upstream.go index 40d28fda..2d70ea0d 100644 --- a/pkg/plugin/implementation/internal/upstream/upstream.go +++ b/pkg/plugin/implementation/internal/upstream/upstream.go @@ -33,7 +33,7 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/oanbinding" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/capabilitybinding" ) // Defaults applied when the registry or the operator leaves a setting out. @@ -164,7 +164,7 @@ type Config struct { // safe for concurrent use. type Step struct { config *Config - paths oanbinding.Paths + paths capabilitybinding.Paths prerequisites Prerequisites registry definition.ProviderRecordLookup mapper definition.Mapper @@ -218,19 +218,19 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d // Both halves or neither: overriding one and leaving the other on the default // is a half-configured deployment that would match nothing, and it would do so // silently on every request rather than once at startup. -func bindingPaths(cfg *Config) (oanbinding.Paths, error) { +func bindingPaths(cfg *Config) (capabilitybinding.Paths, error) { if cfg.ProviderIDAt == "" && cfg.CapabilityCodeAt == "" { - return oanbinding.BecknV2, nil + return capabilitybinding.BecknV2, nil } if cfg.ProviderIDAt == "" { - return oanbinding.Paths{}, errors.New("upstream: capabilityCodeAt is set without providerIdAt") + return capabilitybinding.Paths{}, errors.New("upstream: capabilityCodeAt is set without providerIdAt") } if cfg.CapabilityCodeAt == "" { - return oanbinding.Paths{}, errors.New("upstream: providerIdAt is set without capabilityCodeAt") + return capabilitybinding.Paths{}, errors.New("upstream: providerIdAt is set without capabilityCodeAt") } - paths := oanbinding.Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt} + paths := capabilitybinding.Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt} if err := paths.Validate(); err != nil { - return oanbinding.Paths{}, err + return capabilitybinding.Paths{}, err } return paths, nil } @@ -283,8 +283,8 @@ func applyDefaults(cfg *Config) error { // pipeline and each recognises its own work, so adding a provider is one more // entry rather than a change to a routing table. func (s *Step) Run(ctx *model.StepContext) error { - binding, err := oanbinding.From(s.paths, ctx.Body) - if errors.Is(err, oanbinding.ErrNoBinding) { + binding, err := capabilitybinding.From(s.paths, ctx.Body) + if errors.Is(err, capabilitybinding.ErrNoBinding) { return nil } if err != nil { diff --git a/pkg/plugin/implementation/internal/upstream/upstream_test.go b/pkg/plugin/implementation/internal/upstream/upstream_test.go index 33530e33..2e871dea 100644 --- a/pkg/plugin/implementation/internal/upstream/upstream_test.go +++ b/pkg/plugin/implementation/internal/upstream/upstream_test.go @@ -17,7 +17,7 @@ import ( "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/oanbinding" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/capabilitybinding" ) const selectBody = `{ @@ -1086,7 +1086,7 @@ func TestNewUsesTheBecknConventionByDefault(t *testing.T) { t.Parallel() step := newStep(t, &stubRegistry{}, &stubMapper{}) - if step.paths != oanbinding.BecknV2 { + if step.paths != capabilitybinding.BecknV2 { t.Errorf("paths = %+v, want the Beckn v2 convention", step.paths) } } diff --git a/pkg/plugin/implementation/jsonmapper/README.md b/pkg/plugin/implementation/jsonmapper/README.md index 1f1d5913..e837331a 100644 --- a/pkg/plugin/implementation/jsonmapper/README.md +++ b/pkg/plugin/implementation/jsonmapper/README.md @@ -13,7 +13,7 @@ what a mapping says, and nothing about the payloads passing through. Anything specific to a network or a provider belongs in the caller, which is what lets one mapper serve all of them. -Its first caller is the OAN provider flow, where it translates between Beckn +Its first caller is the provider flow, where it translates between Beckn payloads and each provider's own request and response shapes -- so adding a provider is one mapping file and a registry row rather than another transformation routine. diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index a002b019..1c3d7169 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "slices" "strings" "sync" "time" @@ -78,7 +79,29 @@ type referencedObject struct { Path string Context string Type string - Data map[string]interface{} + // Types is every @type the object carries, in payload order. JSON-LD + // permits the array form and the packs allow it explicitly, so which + // entry names the capability is not known until a document is consulted. + Types []string + Data map[string]interface{} + // Unusable is set when the object claims a domain type but carries it in + // a shape this validator cannot resolve. It is a coded error rather than + // a bool because the object is then rejected, not skipped: a layer whose + // purpose is to turn a missing attribute into a rejection must not answer + // "valid" for an object it never looked at. + Unusable error +} + +// candidateTypes returns the @type values to try, tolerating an object built +// with Type alone -- which every caller outside findReferencedObjects does. +func (o referencedObject) candidateTypes() []string { + if len(o.Types) > 0 { + return o.Types + } + if o.Type == "" { + return nil + } + return []string{o.Type} } // schemaCache caches loaded domain schemas with LRU eviction. @@ -312,7 +335,7 @@ func (c *schemaCache) cleanupExpired() int { return len(expired) } -func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, ttl, timeout time.Duration, localSchema bool) (*openapi3.T, error) { +func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, ttl, timeout time.Duration, allowedDomains []string, localSchema bool) (*openapi3.T, error) { urlHash := hashURL(schemaPath) u, parseErr := url.Parse(schemaPath) @@ -322,6 +345,18 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, loader := newFreshLoader() loader.Context = ctx + // Installed on BOTH branches, with the local-file allowance following + // localSchema. The check at the one @context runs once, on the entry + // document; the $refs inside whatever comes back are resolved by the + // loader, and a pack pulls 13-16 documents, so the refs are the great + // majority of the reads. + // + // localSchema is included because its rawSchemas path falls back to the + // NETWORK for a ref it does not hold -- so a local document could reach an + // arbitrary host, the same exposure by a longer route. What it keeps is + // the file read itself, which in that mode is the operator's stated + // intent rather than something a payload asked for. + loader.ReadFromURIFunc = payloadDirectedReader(allowedDomains, localSchema) var doc *openapi3.T var err error @@ -408,22 +443,80 @@ func (c *schemaCache) loadSchemaFromPath(ctx context.Context, schemaPath string, return doc, nil } +// payloadDirectedReader returns a reader for schema documents whose location a +// payload chose, enforcing the allowlist on EVERY read -- the entry document +// and every $ref under it. +// +// Two separate refusals, for two separate reasons. +// +// SCHEME: freshReadFromURI falls through to os.ReadFile for every scheme but +// http and https, so a $ref of "file:///etc/passwd" -- or a bare path, which +// parses with no scheme at all -- is an instruction from the network to read +// this container's disk and parse it as a schema. The base spec loader keeps +// that fallthrough deliberately: its location is operator-configured, where a +// local file is the point. Here it never is. +// +// HOST: the entry @context is allowlisted, but the document it returns is not +// trusted -- it came from a payload-named URL on a host anyone can publish to. +// Its $refs used to reach any http host at all, so a payload could name an +// attacker's document and have this process fetch whatever that document +// pointed at: an internal service, a cloud metadata endpoint. Checking the +// same allowlist on every read closes that, and makes the allowlist mean what +// it says -- the hosts this deployment will read schemas from, not the hosts +// it will read the FIRST schema from. +// +// This is why the allowlist cannot be a single host: loading one capability +// pack touches raw.githubusercontent.com, schema.beckn.io and +// schema.nfh.global (13-16 reads, measured), so all three have to be named or +// no pack loads at all. An empty allowlist still means "unset, do not check", +// as it does at the @context. +// +// allowLocal follows localSchema: an operator who configured +// extendedSchema_localSchemaPath is asking for files to be read, so the scheme +// refusal does not apply to them. The HOST check still does, because that +// mode falls back to the network for a ref it does not hold locally. +func payloadDirectedReader(allowedDomains []string, allowLocal bool) func(*openapi3.Loader, *url.URL) ([]byte, error) { + return func(loader *openapi3.Loader, u *url.URL) ([]byte, error) { + remote := u.Scheme == "http" || u.Scheme == "https" + if !remote && !allowLocal { + return nil, fmt.Errorf("refusing to read schema from %q: only http and https are read for a location a payload chose", u.String()) + } + if remote && len(allowedDomains) > 0 && !isAllowedDomain(u, allowedDomains) { + return nil, fmt.Errorf("refusing to read schema from %q: host is not in extendedSchema_allowedDomains", u.String()) + } + return freshReadFromURI(loader, u) + } +} + // findReferencedObjects recursively finds domain-specific objects with @context. func findReferencedObjects(data interface{}, path string) []referencedObject { var results []referencedObject switch v := data.(type) { case map[string]interface{}: - // Check for @context and @type - if contextVal, hasContext := v["@context"].(string); hasContext { - if typeVal, hasType := v["@type"].(string); hasType { - results = append(results, referencedObject{ - Path: path, - Context: contextVal, - Type: typeVal, - Data: v, - }) + // @type ABSENT is not the same as @type unreadable. An object with a + // context and no type makes no claim about which schema applies, and + // there is nothing to validate it against, so it is passed over as + // before. An object that does claim a type is validated or rejected. + rawContext, hasContext := v["@context"] + rawType, hasType := v["@type"] + if hasContext && hasType { + obj := referencedObject{Path: path, Data: v} + contextVal, contextOK := jsonLDLocation(rawContext) + types, typesOK := jsonLDTypes(rawType) + switch { + case !contextOK: + obj.Unusable = model.NewCodedError("SCH_INVALID_JSONLD_CONTEXT", + "@context is not a URL this validator can resolve a schema from") + case !typesOK: + obj.Unusable = model.NewCodedError("SCH_INVALID_ENTITY_TYPE", + "@type is present but is not a type name or a list of them") + default: + obj.Context = contextVal + obj.Type = types[0] + obj.Types = types } + results = append(results, obj) } // Recurse into nested objects @@ -452,6 +545,70 @@ func transformContextToSchemaURL(contextURL string) string { return strings.Replace(contextURL, "context.jsonld", "attributes.yaml", 1) } +// jsonLDLocation returns the @context entry a schema can be located from. +// +// JSON-LD allows a string, an array mixing strings and inline objects, or a +// single inline object. Only a URL locates a schema, so the first string is +// taken and an inline object yields nothing -- there is no document to fetch. +func jsonLDLocation(raw interface{}) (string, bool) { + switch v := raw.(type) { + case string: + if v != "" { + return v, true + } + case []interface{}: + for _, entry := range v { + if s, ok := entry.(string); ok && s != "" { + return s, true + } + } + } + return "", false +} + +// jsonLDTypes returns every @type the object carries, in payload order. +// +// The array form is not exotic: the packs declare @type as a oneOf whose +// second branch is an array containing the canonical OAN type plus +// provider-defined ones. Reading only the string form left those objects +// matching nothing, so they were dropped before validation and the layer +// reported a pass over an object it had not looked at. +func jsonLDTypes(raw interface{}) ([]string, bool) { + switch v := raw.(type) { + case string: + if v != "" { + return []string{v}, true + } + case []interface{}: + types := make([]string, 0, len(v)) + for _, entry := range v { + if s, ok := entry.(string); ok && s != "" { + types = append(types, s) + } + } + if len(types) > 0 { + return types, true + } + } + return nil, false +} + +// findSchemaForAnyType resolves the first @type the document declares a schema +// for, and returns which one matched. With the array form the capability type +// sits among provider-defined ones and its position is not fixed, so the +// document decides rather than the payload's ordering. +func findSchemaForAnyType(ctx context.Context, doc *openapi3.T, types []string) (*openapi3.SchemaRef, string, error) { + var lastErr error + for _, typeName := range types { + schema, err := findSchemaByType(ctx, doc, typeName) + if err == nil { + return schema, typeName, nil + } + lastErr = err + } + return nil, "", lastErr +} + // findSchemaByType finds a schema in the document by @type value. func findSchemaByType(ctx context.Context, doc *openapi3.T, typeName string) (*openapi3.SchemaRef, error) { if doc.Components == nil || doc.Components.Schemas == nil { @@ -498,6 +655,69 @@ func isAllowedDomain(u *url.URL, allowedDomains []string) bool { return false } +// jsonLDKeys are the JSON-LD control keys that travel inside a domain object +// rather than beside it. +var jsonLDKeys = []string{"@context", "@type"} + +// stripUnaccountedJSONLDKeys removes those JSON-LD keys the target schema does +// not declare, and keeps the ones it does. +// +// The two schema styles in use need opposite treatment, and removing both keys +// unconditionally only served the first: +// +// - a schema that closes itself with additionalProperties:false and never +// mentions @type rejects the payload if @type is left in; +// - a schema pack that declares @type and lists it in required rejects the +// payload if @type is taken out. +// +// Asking the schema, per key, satisfies both without a config switch and +// without either style having to know about the other. +func stripUnaccountedJSONLDKeys(schema *openapi3.SchemaRef, data map[string]interface{}) map[string]interface{} { + domainData := make(map[string]interface{}, len(data)) + for k, v := range data { + if slices.Contains(jsonLDKeys, k) && !schemaDeclaresProperty(schema, k, map[*openapi3.Schema]bool{}) { + continue + } + domainData[k] = v + } + return domainData +} + +// schemaDeclaresProperty reports whether name is declared as a property, or +// listed as required, anywhere in a schema's composition tree. +// +// allOf, anyOf, oneOf and the then/else branches can each introduce a property, +// so all of them are walked -- the capability packs declare @type one level down, in +// allOf. "not" is skipped because naming a property there forbids it rather +// than permitting it, and "if" is skipped because it only selects a branch. +// seen guards against schemas that reference themselves. +func schemaDeclaresProperty(ref *openapi3.SchemaRef, name string, seen map[*openapi3.Schema]bool) bool { + if ref == nil || ref.Value == nil || seen[ref.Value] { + return false + } + seen[ref.Value] = true + + if _, ok := ref.Value.Properties[name]; ok { + return true + } + if slices.Contains(ref.Value.Required, name) { + return true + } + for _, group := range []openapi3.SchemaRefs{ref.Value.AllOf, ref.Value.AnyOf, ref.Value.OneOf} { + for _, sub := range group { + if schemaDeclaresProperty(sub, name, seen) { + return true + } + } + } + for _, sub := range []*openapi3.SchemaRef{ref.Value.Then, ref.Value.Else} { + if schemaDeclaresProperty(sub, name, seen) { + return true + } + } + return false +} + // validateReferencedObject validates a single object with @context. func (c *schemaCache) validateReferencedObject( ctx context.Context, @@ -506,18 +726,31 @@ func (c *schemaCache) validateReferencedObject( allowedDomains []string, localSchema bool, ) error { + // An object that claims a domain type in a shape we cannot resolve is + // rejected here rather than dropped in findReferencedObjects. Dropping it + // meant the extended layer reported a pass over an object it never + // validated, which is the one outcome this layer exists to prevent. + if obj.Unusable != nil { + log.Warnf(ctx, "refusing an object at %s that carries @context in an unusable shape: %v", obj.Path, obj.Unusable) + return obj.Unusable + } + var doc *openapi3.T if localSchema { - typeName := obj.Type - if idx := strings.LastIndex(typeName, ":"); idx >= 0 { - typeName = typeName[idx+1:] - } - if typeName != "" && !strings.ContainsAny(typeName, "/\\") { - if localDoc, localErr := c.loadSchemaFromPath(ctx, typeName+"/attributes.yaml", ttl, timeout, localSchema); localErr != nil { - log.Debugf(ctx, "local @type lookup failed for %s: %v", obj.Type, localErr) + for _, candidate := range obj.candidateTypes() { + typeName := candidate + if idx := strings.LastIndex(typeName, ":"); idx >= 0 { + typeName = typeName[idx+1:] + } + if typeName == "" || strings.ContainsAny(typeName, "/\\") { + continue + } + if localDoc, localErr := c.loadSchemaFromPath(ctx, typeName+"/attributes.yaml", ttl, timeout, allowedDomains, localSchema); localErr != nil { + log.Debugf(ctx, "local @type lookup failed for %s: %v", candidate, localErr) } else { doc = localDoc + break } } } @@ -539,26 +772,24 @@ func (c *schemaCache) validateReferencedObject( schemaPath := transformContextToSchemaURL(obj.Context) log.Debugf(ctx, "Transformed %s -> %s (localSchema=%v)", obj.Context, schemaPath, localSchema) var err error - doc, err = c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout, false) + doc, err = c.loadSchemaFromPath(ctx, schemaPath, ttl, timeout, allowedDomains, false) if err != nil { return model.NewCodedErrorWithCause("SCH_SCHEMA_ADAPTATION_FAILED", err.Error(), obj.Path, err) } } // Find schema by @type - schema, err := findSchemaByType(ctx, doc, obj.Type) + schema, matched, err := findSchemaForAnyType(ctx, doc, obj.candidateTypes()) + if err == nil && matched != obj.Type { + log.Debugf(ctx, "resolved @type %s from the array at %s", matched, obj.Path) + } if err != nil { log.Errorf(ctx, err, "Schema not found for @type: %s at path: %s", obj.Type, obj.Path) return model.NewCodedErrorWithCause("SCH_INVALID_ENTITY_TYPE", err.Error(), obj.Path, err) } - // Strip JSON-LD metadata before validation - domainData := make(map[string]interface{}, len(obj.Data)-2) - for k, v := range obj.Data { - if k != "@context" && k != "@type" { - domainData[k] = v - } - } + // Strip only the JSON-LD keys this schema does not account for itself. + domainData := stripUnaccountedJSONLDKeys(schema, obj.Data) // Validate domain-specific data against schema opts := []openapi3.SchemaValidationOption{ diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go index 6adc99be..9facca44 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "reflect" + "sort" + "strings" "sync/atomic" "testing" "time" @@ -155,10 +157,10 @@ func TestHashURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { hash1 := hashURL(tt.url) hash2 := hashURL(tt.url) - + // Same URL should produce same hash assert.Equal(t, hash1, hash2) - + // Hash should be 64 characters (SHA256 hex) assert.Equal(t, 64, len(hash1)) }) @@ -246,23 +248,23 @@ func TestNewSchemaCache(t *testing.T) { func TestSchemaCache_GetSet(t *testing.T) { cache := newSchemaCache(10) - + // Create a simple schema doc doc := &openapi3.T{ OpenAPI: "3.1.0", } - + urlHash := hashURL("https://example.com/schema.yaml") ttl := 1 * time.Hour - + // Test Set cache.set(urlHash, doc, ttl) - + // Test Get - should find it retrieved, found := cache.get(urlHash) assert.True(t, found) assert.Equal(t, doc, retrieved) - + // Test Get - non-existent key _, found = cache.get("non-existent-hash") assert.False(t, found) @@ -270,28 +272,28 @@ func TestSchemaCache_GetSet(t *testing.T) { func TestSchemaCache_LRUEviction(t *testing.T) { cache := newSchemaCache(2) // Small cache for testing - + doc1 := &openapi3.T{OpenAPI: "3.1.0"} doc2 := &openapi3.T{OpenAPI: "3.1.1"} doc3 := &openapi3.T{OpenAPI: "3.1.2"} - + ttl := 1 * time.Hour - + // Add first two items cache.set("hash1", doc1, ttl) cache.set("hash2", doc2, ttl) - + // Access first item to make it more recent cache.get("hash1") - + // Add third item - should evict hash2 (least recently used) cache.set("hash3", doc3, ttl) - + // Verify hash1 and hash3 exist, hash2 was evicted _, found1 := cache.get("hash1") _, found2 := cache.get("hash2") _, found3 := cache.get("hash3") - + assert.True(t, found1, "hash1 should exist (recently accessed)") assert.False(t, found2, "hash2 should be evicted (LRU)") assert.True(t, found3, "hash3 should exist (just added)") @@ -299,20 +301,20 @@ func TestSchemaCache_LRUEviction(t *testing.T) { func TestSchemaCache_TTLExpiry(t *testing.T) { cache := newSchemaCache(10) - + doc := &openapi3.T{OpenAPI: "3.1.0"} urlHash := "test-hash" - + // Set with very short TTL cache.set(urlHash, doc, 1*time.Millisecond) - + // Should be found immediately _, found := cache.get(urlHash) assert.True(t, found) - + // Wait for expiry time.Sleep(10 * time.Millisecond) - + // Should not be found after expiry _, found = cache.get(urlHash) assert.False(t, found) @@ -320,23 +322,23 @@ func TestSchemaCache_TTLExpiry(t *testing.T) { func TestSchemaCache_CleanupExpired(t *testing.T) { cache := newSchemaCache(10) - + doc := &openapi3.T{OpenAPI: "3.1.0"} - + // Add items with short TTL cache.set("hash1", doc, 1*time.Millisecond) cache.set("hash2", doc, 1*time.Millisecond) cache.set("hash3", doc, 1*time.Hour) // This one won't expire - + // Wait for expiry time.Sleep(10 * time.Millisecond) - + // Cleanup expired count := cache.cleanupExpired() - + // Should have cleaned up 2 expired items assert.Equal(t, 2, count) - + // Verify only hash3 remains cache.mu.RLock() assert.Equal(t, 1, len(cache.schemas)) @@ -447,7 +449,7 @@ func TestFindReferencedObjects_PathBuilding(t *testing.T) { } objects := findReferencedObjects(data, "message") - + assert.Equal(t, 1, len(objects)) assert.Equal(t, "message.order.beckn:orderItems[0].beckn:acceptedOffer.beckn:offerAttributes", objects[0].Path) assert.Equal(t, "ChargingOffer", objects[0].Type) @@ -458,11 +460,11 @@ func TestFindReferencedObjects_PathBuilding(t *testing.T) { func TestLoadSchemaFromPath_LocalFile(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -474,58 +476,74 @@ components: properties: field1: type: string` - + _, err = tmpFile.Write([]byte(schemaContent)) assert.NoError(t, err) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + // localSchema=false means the location came from a payload's @context -- + // the only way the production caller passes it. A local file is not + // something the network may ask this process to open, so it is refused. + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, false) + if err == nil { + t.Fatal("a payload-directed load opened a local file") + } + assert.Contains(t, err.Error(), "only http and https are read") + assert.Nil(t, doc) + + // localSchema=true is an operator naming a path in the adapter's own + // config, which is the one case where opening a file is the intent. + doc, err = cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "3.1.0", doc.OpenAPI) } func TestLoadSchemaFromPath_CacheHit(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema version: 1.0.0` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc1, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc1, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) - doc2, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + doc2, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) - + assert.Equal(t, doc1, doc2) } func TestLoadSchemaFromPath_InvalidPath(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - _, err := cache.loadSchemaFromPath(ctx, "/nonexistent/schema.yaml", 1*time.Hour, 30*time.Second, false) + + _, err := cache.loadSchemaFromPath(ctx, "/nonexistent/schema.yaml", 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) } func TestFindSchemaByType_DirectMatch(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -537,11 +555,11 @@ components: properties: field1: type: string` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) schema, err := findSchemaByType(ctx, doc, "TestType") @@ -550,13 +568,15 @@ components: } func TestFindSchemaByType_NotFound(t *testing.T) { + // A temp file is just the fixture here, so this loads in operator mode: + // a payload-directed load refuses local files by design. cache := newSchemaCache(10) ctx := context.Background() - + tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") assert.NoError(t, err) defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -565,11 +585,11 @@ components: schemas: TestType: type: object` - + tmpFile.Write([]byte(schemaContent)) tmpFile.Close() - - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, false) + + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) _, err = findSchemaByType(ctx, doc, "NonExistentType") @@ -580,11 +600,7 @@ components: func TestValidateReferencedObject_Valid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -602,33 +618,28 @@ components: type: string required: - field1` - - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() - + + ctxURL := serveTempSchema(t, schemaContent) + obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", "field1": "value1", }, } - - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) } func TestValidateReferencedObject_Invalid(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - + schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -646,21 +657,20 @@ components: type: string required: - field1` - - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() - + + ctxURL := serveTempSchema(t, schemaContent) + obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", }, } - - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) schemaErrors := []model.Error{} @@ -697,10 +707,6 @@ func TestValidateReferencedObject_EntityTypeNotFound(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - schemaContent := `openapi: 3.1.0 info: title: Test Schema @@ -710,20 +716,19 @@ components: TestType: type: object` - tmpFile.Write([]byte(schemaContent)) - tmpFile.Close() + ctxURL := serveTempSchema(t, schemaContent) obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "NonExistentType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "NonExistentType", }, } - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, false) assert.Error(t, err) becknErr, ok := err.(*model.Error) @@ -830,7 +835,7 @@ components: - field1` tests := []struct { - name string + name string allowedDomains []string }{ {name: "file scheme allowed when no allowlist (nil)", allowedDomains: nil}, @@ -849,6 +854,8 @@ components: ctx := context.Background() // Use file:// scheme — would be rejected by scheme check if allowlist were set. + // It is still refused, one layer down: the reader takes http and + // https only. What an empty allowlist skips is the HOST check. obj := referencedObject{ Path: "message.test", Context: "file://" + tmpFile.Name(), @@ -857,7 +864,8 @@ components: } err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, tt.allowedDomains, false) - // No domain or scheme error — allowlist check was skipped entirely. + // So: no domain error and no @context scheme error, which is what + // an empty allowlist means. Not "anything is readable". if err != nil { assert.NotContains(t, err.Error(), "domain not allowed") assert.NotContains(t, err.Error(), "invalid scheme in @context") @@ -874,14 +882,14 @@ func TestValidateExtendedSchemas_NoObjects(t *testing.T) { }, schemaCache: newSchemaCache(10), } - + ctx := context.Background() body := map[string]interface{}{ "message": map[string]interface{}{ "field": "value", }, } - + err := v.validateExtendedSchemas(ctx, body) assert.NoError(t, err) } @@ -893,12 +901,12 @@ func TestValidateExtendedSchemas_MissingMessage(t *testing.T) { }, schemaCache: newSchemaCache(10), } - + ctx := context.Background() body := map[string]interface{}{ "context": map[string]interface{}{}, } - + err := v.validateExtendedSchemas(ctx, body) assert.Error(t, err) assert.Contains(t, err.Error(), "missing 'message' field") @@ -1054,9 +1062,9 @@ func TestIsSchemaVersionSegment(t *testing.T) { func TestExtractRelativeSchemaPath(t *testing.T) { tests := []struct { - name string + name string rawURL string - want string + want string }{ { name: "URL with /schema/ marker and version", @@ -1201,7 +1209,7 @@ components: cache.rawSchemas["TestType/attributes.yaml"] = []byte(schemaContent) - doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, true) + doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "3.1.0", doc.OpenAPI) @@ -1215,7 +1223,7 @@ func TestLoadSchemaFromPath_LRUHit(t *testing.T) { cache.set(hashURL("TestType/attributes.yaml"), expected, 1*time.Hour) // localSchema=false skips rawSchemas step, goes straight to LRU - doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, false) + doc, err := cache.loadSchemaFromPath(ctx, "TestType/attributes.yaml", 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, expected, doc) } @@ -1235,7 +1243,7 @@ info: tmpFile.Close() // rawSchemas empty, localSchema=true — local miss, falls through to file load - doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, true) + doc, err := cache.loadSchemaFromPath(ctx, tmpFile.Name(), 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) assert.NotNil(t, doc) } @@ -1274,11 +1282,7 @@ func TestValidateReferencedObject_LocalMissFallsBackToContext(t *testing.T) { cache := newSchemaCache(10) ctx := context.Background() - tmpFile, err := os.CreateTemp("", "test-schema-*.yaml") - assert.NoError(t, err) - defer os.Remove(tmpFile.Name()) - - tmpFile.Write([]byte(`openapi: 3.1.0 + ctxURL := serveTempSchema(t, `openapi: 3.1.0 info: title: Test Schema version: 1.0.0 @@ -1288,22 +1292,21 @@ components: type: object properties: field1: - type: string`)) - tmpFile.Close() + type: string`) obj := referencedObject{ Path: "message.test", - Context: tmpFile.Name(), + Context: ctxURL, Type: "TestType", Data: map[string]interface{}{ - "@context": tmpFile.Name(), + "@context": ctxURL, "@type": "TestType", "field1": "value1", }, } - // rawSchemas empty, localSchema=true — local miss, falls back to @context file path - err = cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, true) + // rawSchemas empty, localSchema=true — local miss, falls back to fetching the @context + err := cache.validateReferencedObject(ctx, obj, 1*time.Hour, 30*time.Second, nil, true) assert.NoError(t, err) } @@ -1364,7 +1367,7 @@ func TestLoadSchemaFromPath_TTLExpiry_FetchesFresh(t *testing.T) { ctx := context.Background() // Load v1 with a 1ms TTL so the LRU entry expires almost immediately. - doc1, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Millisecond, 30*time.Second, false) + doc1, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Millisecond, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, "Schema v1", doc1.Info.Title) @@ -1373,7 +1376,614 @@ func TestLoadSchemaFromPath_TTLExpiry_FetchesFresh(t *testing.T) { serveV2.Store(true) // Re-load — LRU miss (expired), freshReadFromURI fetches from the server and gets v2. - doc2, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Hour, 30*time.Second, false) + doc2, err := cache.loadSchemaFromPath(ctx, server.URL, 1*time.Hour, 30*time.Second, nil, false) assert.NoError(t, err) assert.Equal(t, "Schema v2", doc2.Info.Title, "expected v2 after TTL expiry — global URIMapCache not bypassed") } + +// packStyleSchema mirrors how the capability schema packs are shaped: the capability +// declares @type one level down in allOf and lists it as required, and nothing +// closes the object with additionalProperties:false. +const packStyleSchema = `openapi: 3.1.0 +info: + title: Pack Style + version: 1.0.0 +components: + schemas: + WeatherObservation: + type: object + x-jsonld: + "@context": https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld + "@type": openagrinet:WeatherObservation + allOf: + - type: object + required: + - informationMode + properties: + informationMode: + type: string + enum: [OnDemand, Direct] + - type: object + required: + - "@type" + properties: + "@type": + type: string + const: openagrinet:WeatherObservation` + +// serveTempSchema serves content over http and returns a URL usable as an +// @context. Served rather than written to disk because a payload-directed load +// reads http and https only -- and because fetching is what production does. +func serveTempSchema(t *testing.T, content string) string { + t.Helper() + return serveSchema(t, content).URL + "/context.jsonld" +} + +// A pack that requires @type must receive it. This is the case that could not +// validate while both JSON-LD keys were removed unconditionally: the payload +// carries @type, the schema requires it, and stripping it produced a spurious +// "@type is required". +func TestValidateReferencedObject_PackStyleKeepsAtType(t *testing.T) { + cache := newSchemaCache(10) + path := serveTempSchema(t, packStyleSchema) + + obj := referencedObject{ + Path: "message.catalogs[0].resources[0].resourceAttributes", + Context: path, + Type: "openagrinet:WeatherObservation", + Data: map[string]interface{}{ + "@context": "https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "informationMode": "OnDemand", + }, + } + + err := cache.validateReferencedObject(context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + assert.NoError(t, err) +} + +// packStyleTypeListSchema mirrors how the packs really declare @type: a oneOf +// whose first branch is the canonical string and whose second is a list +// carrying that type alongside provider-defined ones, which must not take the +// openagrinet: prefix. +const packStyleTypeListSchema = `openapi: 3.1.0 +info: + title: Pack Style With Type List + version: 1.0.0 +components: + schemas: + WeatherObservation: + type: object + x-jsonld: + "@context": https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld + "@type": openagrinet:WeatherObservation + allOf: + - type: object + required: + - informationMode + properties: + informationMode: + type: string + enum: [OnDemand, Direct] + - type: object + required: + - "@type" + properties: + "@type": + oneOf: + - type: string + const: openagrinet:WeatherObservation + - type: array + minItems: 2 + uniqueItems: true + contains: + const: openagrinet:WeatherObservation + items: + oneOf: + - const: openagrinet:WeatherObservation + - type: string + minLength: 1 + not: + pattern: "^openagrinet:"` + +// resourceBody wraps resourceAttributes the way a payload carries them, so +// discovery runs over the same shape production sees. +func resourceBody(ctxURL string, atType interface{}, informationMode string) map[string]interface{} { + attrs := map[string]interface{}{"@context": ctxURL, "@type": atType} + if informationMode != "" { + attrs["informationMode"] = informationMode + } + return map[string]interface{}{ + "message": map[string]interface{}{ + "catalogs": []interface{}{ + map[string]interface{}{"resources": []interface{}{ + map[string]interface{}{"resourceAttributes": attrs}, + }}, + }, + }, + } +} + +// theObjectIn runs the production discovery over a body and returns the single +// domain object in it. Tests go through this rather than building a +// referencedObject by hand: Context, Type and Data all come off one map there, +// so a hand-built object can assert a state the real path cannot produce. +func theObjectIn(t *testing.T, body map[string]interface{}) referencedObject { + t.Helper() + objects := findReferencedObjects(body["message"], "message") + if len(objects) != 1 { + t.Fatalf("expected exactly one domain object from discovery, got %d", len(objects)) + } + return objects[0] +} + +// The pack allows @type to be a list, and reading only the string form meant +// such an object matched nothing, was dropped before validation, and the layer +// reported a pass over a payload it had not looked at. +func TestValidateReferencedObject_AcceptsAndChecksATypeList(t *testing.T) { + ctxURL := serveTempSchema(t, packStyleTypeListSchema) + const canonical = "openagrinet:WeatherObservation" + + for _, tt := range []struct { + name string + atType interface{} + wantErr bool + wantErrHas string + }{ + { + name: "the canonical type alone, as a string", + atType: canonical, + }, + { + name: "the canonical type beside a provider type", + atType: []interface{}{canonical, "vendor:GriddedForecast"}, + }, + { + name: "provider type first -- the document decides which entry names the capability", + atType: []interface{}{"vendor:GriddedForecast", canonical}, + }, + { + // A real payload-level rejection, and one that only bites because + // @type is kept in the data rather than stripped: the list branch + // forbids a second openagrinet: type. + name: "a second openagrinet type, which the pack forbids", + atType: []interface{}{canonical, "openagrinet:MandiPrice"}, + wantErr: true, + }, + { + // The string branch does not match a list and the list branch + // requires two entries, so neither is satisfied. + name: "a single-entry list, which satisfies neither branch", + atType: []interface{}{canonical}, + wantErr: true, + }, + { + name: "a list naming no type the document declares", + atType: []interface{}{"vendor:One", "vendor:Two"}, + wantErr: true, + wantErrHas: "no schema found", + }, + { + name: "@type present but not a type name", + atType: 42, + wantErr: true, + wantErrHas: "not a type name", + }, + { + name: "@type an empty list", + atType: []interface{}{}, + wantErr: true, + wantErrHas: "not a type name", + }, + } { + t.Run(tt.name, func(t *testing.T) { + obj := theObjectIn(t, resourceBody(ctxURL, tt.atType, "OnDemand")) + err := newSchemaCache(10).validateReferencedObject( + context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + + if !tt.wantErr { + assert.NoError(t, err) + return + } + if err == nil { + t.Fatal("expected a rejection; a skipped object is reported as valid") + } + if tt.wantErrHas != "" { + assert.Contains(t, err.Error(), tt.wantErrHas) + } + }) + } +} + +// An object claiming a type this validator cannot read must be rejected, not +// passed over. Skipping is what let unvalidated resourceAttributes through. +func TestFindReferencedObjects_TypeShapes(t *testing.T) { + const ctxURL = "https://schemas.example.org/schema/WeatherObservation/v0.1/context.jsonld" + + for _, tt := range []struct { + name string + attrs map[string]interface{} + wantFound bool + wantTypes []string + wantCode string + }{ + { + name: "string @type", + attrs: map[string]interface{}{"@context": ctxURL, "@type": "openagrinet:WeatherObservation"}, + wantFound: true, + wantTypes: []string{"openagrinet:WeatherObservation"}, + }, + { + name: "list @type keeps every entry, in payload order", + attrs: map[string]interface{}{"@context": ctxURL, "@type": []interface{}{"a", "b"}}, + wantFound: true, + wantTypes: []string{"a", "b"}, + }, + { + name: "list @context takes the first string, since only a URL locates a schema", + attrs: map[string]interface{}{"@context": []interface{}{ctxURL, map[string]interface{}{"inline": "term"}}, "@type": "T"}, + wantFound: true, + wantTypes: []string{"T"}, + }, + { + name: "inline-object @context names no document to fetch", + attrs: map[string]interface{}{"@context": map[string]interface{}{"inline": "term"}, "@type": "T"}, + wantFound: true, + wantCode: "SCH_INVALID_JSONLD_CONTEXT", + }, + { + name: "@type a number", + attrs: map[string]interface{}{"@context": ctxURL, "@type": 42}, + wantFound: true, + wantCode: "SCH_INVALID_ENTITY_TYPE", + }, + { + // No claim about which schema applies, so there is nothing to + // validate against. Passed over, as before. + name: "@context with no @type at all", + attrs: map[string]interface{}{"@context": ctxURL, "field": "value"}, + wantFound: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + objects := findReferencedObjects(map[string]interface{}{"resourceAttributes": tt.attrs}, "message") + if !tt.wantFound { + assert.Empty(t, objects) + return + } + if len(objects) != 1 { + t.Fatalf("expected one object, got %d", len(objects)) + } + obj := objects[0] + + if tt.wantCode != "" { + if obj.Unusable == nil { + t.Fatal("expected the object to be marked unusable, so it is rejected rather than skipped") + } + becknErr, ok := obj.Unusable.(*model.Error) + if !ok { + t.Fatalf("Unusable = %T, want *model.Error", obj.Unusable) + } + assert.Equal(t, tt.wantCode, becknErr.Code) + + // and it must actually reject when validated + err := newSchemaCache(10).validateReferencedObject( + context.Background(), obj, 1*time.Hour, 30*time.Second, nil, false) + assert.Error(t, err) + return + } + + assert.Nil(t, obj.Unusable) + assert.Equal(t, tt.wantTypes, obj.Types) + assert.Equal(t, tt.wantTypes[0], obj.Type) + assert.Equal(t, ctxURL, obj.Context) + }) + } +} + +func TestStripUnaccountedJSONLDKeys(t *testing.T) { + declaresType := &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{ + {Value: &openapi3.Schema{ + Required: []string{"@type"}, + Properties: openapi3.Schemas{"@type": {Value: &openapi3.Schema{}}}, + }}, + }, + }} + declaresNeither := &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{"field1": {Value: &openapi3.Schema{}}}, + }} + declaresBoth := &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{ + "@context": {Value: &openapi3.Schema{}}, + "@type": {Value: &openapi3.Schema{}}, + }, + }} + + data := map[string]interface{}{ + "@context": "https://example.com/context.jsonld", + "@type": "openagrinet:WeatherObservation", + "field1": "value1", + } + + tests := []struct { + name string + schema *openapi3.SchemaRef + want []string + }{ + {"pack declares @type, so only @context goes", declaresType, []string{"@type", "field1"}}, + {"schema declares neither, so both go", declaresNeither, []string{"field1"}}, + {"schema declares both, so neither goes", declaresBoth, []string{"@context", "@type", "field1"}}, + {"nil schema is treated as declaring nothing", nil, []string{"field1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripUnaccountedJSONLDKeys(tt.schema, data) + keys := make([]string, 0, len(got)) + for k := range got { + keys = append(keys, k) + } + sort.Strings(keys) + assert.Equal(t, tt.want, keys) + // the input must not be mutated -- obj.Data is shared with the caller + assert.Len(t, data, 3) + }) + } +} + +func TestSchemaDeclaresProperty(t *testing.T) { + leaf := func(required ...string) *openapi3.SchemaRef { + return &openapi3.SchemaRef{Value: &openapi3.Schema{Required: required}} + } + + cyclic := &openapi3.SchemaRef{Value: &openapi3.Schema{}} + cyclic.Value.AllOf = openapi3.SchemaRefs{cyclic} + + tests := []struct { + name string + schema *openapi3.SchemaRef + want bool + }{ + {"nil ref", nil, false}, + {"nil value", &openapi3.SchemaRef{}, false}, + {"declared directly as a property", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Properties: openapi3.Schemas{"@type": {Value: &openapi3.Schema{}}}, + }}, true}, + {"required directly", leaf("@type"), true}, + {"required inside allOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{leaf("other"), leaf("@type")}, + }}, true}, + {"required inside anyOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AnyOf: openapi3.SchemaRefs{leaf("@type")}, + }}, true}, + {"required inside oneOf", &openapi3.SchemaRef{Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{leaf("@type")}, + }}, true}, + {"required inside then", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Then: leaf("@type"), + }}, true}, + {"required inside else", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Else: leaf("@type"), + }}, true}, + // naming a property under "not" forbids it, so it must not count as declared + {"named under not does not count", &openapi3.SchemaRef{Value: &openapi3.Schema{ + Not: leaf("@type"), + }}, false}, + // "if" only selects a branch; it does not permit the property + {"named under if does not count", &openapi3.SchemaRef{Value: &openapi3.Schema{ + If: leaf("@type"), + }}, false}, + {"absent everywhere", &openapi3.SchemaRef{Value: &openapi3.Schema{ + AllOf: openapi3.SchemaRefs{leaf("informationMode")}, + }}, false}, + {"self-referencing schema terminates", cyclic, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := schemaDeclaresProperty(tt.schema, "@type", map[*openapi3.Schema]bool{}) + assert.Equal(t, tt.want, got) + }) + } +} + +// A payload chooses the @context, so it chooses every document the loader then +// reads to resolve that document's $refs. The allowlist is consulted once, on +// the entry URL; these tests cover what happens after it. + +const entrySchemaRefTemplate = `openapi: 3.1.0 +info: + title: entry + version: "1" +paths: {} +components: + schemas: + TestType: + type: object + properties: + field1: + $ref: "REF_TARGET#/components/schemas/Borrowed" +` + +const borrowedSchema = `openapi: 3.1.0 +info: + title: borrowed + version: "1" +paths: {} +components: + schemas: + Borrowed: + type: string +` + +// serveSchema returns an https-less test server answering every path with body. +func serveSchema(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestValidateReferencedObject_RefusesARefThatWouldReadTheDisk(t *testing.T) { + // A real file, so a successful read would be indistinguishable from a + // legitimate schema and the test could not tell the two apart. + onDisk := filepath.Join(t.TempDir(), "borrowed.yaml") + if err := os.WriteFile(onDisk, []byte(borrowedSchema), 0o600); err != nil { + t.Fatalf("failed to write the file under test: %v", err) + } + + for _, tt := range []struct { + name string + ref string + }{ + {name: "file scheme", ref: "file://" + onDisk}, + {name: "bare path, which parses with no scheme at all", ref: onDisk}, + } { + t.Run(tt.name, func(t *testing.T) { + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", tt.ref, 1)) + host, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Data: map[string]interface{}{"field1": "value1"}, + } + + err = cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{host.Host}, false) + + // The entry document is allowlisted and https, so nothing before + // the $ref refuses this. Only the reader can. + if err == nil { + t.Fatal("the $ref was read, so a payload can name any file on disk") + } + assert.Contains(t, err.Error(), "refusing to read schema from") + }) + } +} + +// A $ref may not reach a host the allowlist does not name. +// +// The entry @context being allowlisted is not enough. The document it returns +// is NOT trusted -- it came from a URL the payload chose, on a host anyone can +// publish to -- so its $refs used to reach any http host at all. That let a +// payload name an attacker's document and have this process fetch whatever +// that document pointed at: an internal service, a cloud metadata endpoint. +// +// This is the case the allowlist has to cover to mean anything, because the +// refs are the great majority of the reads: one pack pulls 13-16 documents. +func TestValidateReferencedObject_RefusesARefToAHostOutsideTheAllowlist(t *testing.T) { + borrowed := serveSchema(t, borrowedSchema) + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", borrowed.URL+"/borrowed.yaml", 1)) + + entryHost, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + borrowedHost, err := url.Parse(borrowed.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + if entryHost.Port() == borrowedHost.Port() { + t.Fatal("the two servers must differ, or this proves nothing") + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Types: []string{"TestType"}, + Data: map[string]interface{}{"field1": "value1"}, + } + + // Only the entry host is allowlisted. The $ref host is not. + err = cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{entryHost.Host}, false) + if err == nil { + t.Fatal("the cross-host $ref was fetched; a payload can point this process at any http host") + } + assert.Contains(t, err.Error(), "not in extendedSchema_allowedDomains") +} + +// And naming both hosts loads it, which is the case the packs need: a +// capability pack $refs schema.beckn.io, which $refs schema.nfh.global, so the +// allowlist has to carry every host in the chain or nothing loads. +func TestValidateReferencedObject_AllowsARefWhenBothHostsAreAllowlisted(t *testing.T) { + borrowed := serveSchema(t, borrowedSchema) + entry := serveSchema(t, strings.Replace(entrySchemaRefTemplate, "REF_TARGET", borrowed.URL+"/borrowed.yaml", 1)) + + entryHost, err := url.Parse(entry.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + borrowedHost, err := url.Parse(borrowed.URL) + if err != nil { + t.Fatalf("failed to parse the test server URL: %v", err) + } + + cache := newSchemaCache(10) + obj := referencedObject{ + Path: "message.test", + Context: entry.URL + "/context.jsonld", + Type: "TestType", + Types: []string{"TestType"}, + Data: map[string]interface{}{"field1": "value1"}, + } + + if err := cache.validateReferencedObject(context.Background(), obj, + 1*time.Hour, 30*time.Second, []string{entryHost.Host, borrowedHost.Host}, false); err != nil { + t.Fatalf("both hosts allowlisted, so the chain must load: %v", err) + } +} + +func TestPayloadDirectedReader(t *testing.T) { + onDisk := filepath.Join(t.TempDir(), "schema.yaml") + if err := os.WriteFile(onDisk, []byte(borrowedSchema), 0o600); err != nil { + t.Fatalf("failed to write the file under test: %v", err) + } + + for _, tt := range []struct { + name string + raw string + refused bool + }{ + {name: "file scheme", raw: "file://" + onDisk, refused: true}, + {name: "bare path", raw: onDisk, refused: true}, + {name: "a scheme nobody serves schemas over", raw: "gopher://example.test/schema.yaml", refused: true}, + {name: "http is read", raw: "", refused: false}, + } { + t.Run(tt.name, func(t *testing.T) { + raw := tt.raw + if raw == "" { + raw = serveSchema(t, borrowedSchema).URL + "/schema.yaml" + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("failed to parse %q: %v", raw, err) + } + + data, err := payloadDirectedReader(nil, false)(openapi3.NewLoader(), u) + if tt.refused { + if err == nil { + t.Fatalf("%q was read, and must not have been", raw) + } + assert.Contains(t, err.Error(), "only http and https are read") + // The point is that nothing was read, not merely that it errored. + assert.Empty(t, data) + return + } + assert.NoError(t, err) + assert.Contains(t, string(data), "Borrowed") + }) + } +} diff --git a/pkg/plugin/implementation/oanregistry/README.md b/pkg/plugin/implementation/sunbirdRegistry/README.md similarity index 99% rename from pkg/plugin/implementation/oanregistry/README.md rename to pkg/plugin/implementation/sunbirdRegistry/README.md index cbce9a81..459d51fe 100644 --- a/pkg/plugin/implementation/oanregistry/README.md +++ b/pkg/plugin/implementation/sunbirdRegistry/README.md @@ -1,6 +1,6 @@ -# OAN Registry Plugin +# SunbirdRC Registry Plugin -A **registry type plugin** for Beckn-ONIX that reads the OAN Registry, a +A **registry type plugin** for Beckn-ONIX that reads a SunbirdRC registry, a [SunbirdRC](https://docs.sunbirdrc.dev/) deployment. ## Overview @@ -31,7 +31,7 @@ request spends waiting before it can even be rejected. ```yaml registry: - id: oanregistry + id: sunbirdRegistry config: url: http://registry:8081/api/v1 entity: Participant diff --git a/pkg/plugin/implementation/oanregistry/cmd/plugin.go b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go similarity index 75% rename from pkg/plugin/implementation/oanregistry/cmd/plugin.go rename to pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go index 461432a6..5992bf4b 100644 --- a/pkg/plugin/implementation/oanregistry/cmd/plugin.go +++ b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go @@ -9,33 +9,33 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/oanregistry" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/sunbirdRegistry" ) // Defaults for settings an operator leaves out. Only parseConfig can tell // "absent" from "explicitly zero" -- retry_max of 0 is a legitimate "do not // retry" -- so they are applied here. The values themselves live in the -// oanregistry package so there is exactly one place to change them. +// sunbirdRegistry package so there is exactly one place to change them. const ( - defaultEntity = oanregistry.DefaultEntity - defaultProviderEntity = oanregistry.DefaultProviderEntity - defaultTimeout = oanregistry.DefaultTimeoutSeconds - defaultRetryMax = oanregistry.DefaultRetryMax - defaultRetryWaitMin = oanregistry.DefaultRetryWaitMin - defaultRetryWaitMax = oanregistry.DefaultRetryWaitMax + defaultEntity = sunbirdRegistry.DefaultEntity + defaultProviderEntity = sunbirdRegistry.DefaultProviderEntity + defaultTimeout = sunbirdRegistry.DefaultTimeoutSeconds + defaultRetryMax = sunbirdRegistry.DefaultRetryMax + defaultRetryWaitMin = sunbirdRegistry.DefaultRetryWaitMin + defaultRetryWaitMax = sunbirdRegistry.DefaultRetryWaitMax ) -// oanRegistryProvider implements the RegistryLookupProvider interface for the -// OAN registry plugin. -type oanRegistryProvider struct{} +// sunbirdRegistryProvider implements the RegistryLookupProvider interface for the +// registry plugin. +type sunbirdRegistryProvider struct{} -// newOANRegistryFunc creates a new OAN registry client. Indirected for tests. -var newOANRegistryFunc = oanregistry.New +// newSunbirdRegistryFunc creates a new registry client. Indirected for tests. +var newSunbirdRegistryFunc = sunbirdRegistry.New -// parseConfig parses the configuration map into an oanregistry.Config, starting +// parseConfig parses the configuration map into an sunbirdRegistry.Config, starting // from the defaults and overriding whatever the operator supplied. -func (o oanRegistryProvider) parseConfig(config map[string]string) (*oanregistry.Config, error) { - cfg := &oanregistry.Config{ +func (o sunbirdRegistryProvider) parseConfig(config map[string]string) (*sunbirdRegistry.Config, error) { + cfg := &sunbirdRegistry.Config{ URL: config["url"], Entity: defaultEntity, ProviderEntity: defaultProviderEntity, @@ -135,8 +135,8 @@ func (o oanRegistryProvider) parseConfig(config map[string]string) (*oanregistry return cfg, nil } -// New creates a new OAN registry plugin instance. -func (o oanRegistryProvider) New(ctx context.Context, cache definition.Cache, config map[string]string) (definition.RegistryLookup, func() error, error) { +// New creates a new registry plugin instance. +func (o sunbirdRegistryProvider) New(ctx context.Context, cache definition.Cache, config map[string]string) (definition.RegistryLookup, func() error, error) { if ctx == nil { return nil, nil, errors.New("context cannot be nil") } @@ -144,12 +144,12 @@ func (o oanRegistryProvider) New(ctx context.Context, cache definition.Cache, co cfg, err := o.parseConfig(config) if err != nil { log.Errorf(ctx, err, "Failed to parse OAN registry configuration") - return nil, nil, fmt.Errorf("failed to parse oan registry configuration: %w", err) + return nil, nil, fmt.Errorf("failed to parse registry configuration: %w", err) } log.Debugf(ctx, "OAN registry config mapped: %+v", cfg) - client, closer, err := newOANRegistryFunc(ctx, cache, cfg) + client, closer, err := newSunbirdRegistryFunc(ctx, cache, cfg) if err != nil { log.Errorf(ctx, err, "Failed to create OAN registry instance") return nil, nil, err @@ -160,4 +160,4 @@ func (o oanRegistryProvider) New(ctx context.Context, cache definition.Cache, co } // Provider is the exported plugin instance. -var Provider = oanRegistryProvider{} +var Provider = sunbirdRegistryProvider{} diff --git a/pkg/plugin/implementation/oanregistry/cmd/plugin_test.go b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go similarity index 77% rename from pkg/plugin/implementation/oanregistry/cmd/plugin_test.go rename to pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go index 8d36d67a..fb2dfec5 100644 --- a/pkg/plugin/implementation/oanregistry/cmd/plugin_test.go +++ b/pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go @@ -9,11 +9,11 @@ import ( "time" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/oanregistry" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/sunbirdRegistry" ) -func defaultConfig() *oanregistry.Config { - return &oanregistry.Config{ +func defaultConfig() *sunbirdRegistry.Config { + return &sunbirdRegistry.Config{ Entity: defaultEntity, ProviderEntity: defaultProviderEntity, Timeout: defaultTimeout, @@ -26,7 +26,7 @@ func defaultConfig() *oanregistry.Config { func TestParseConfig(t *testing.T) { t.Parallel() - withDefaults := func(apply func(*oanregistry.Config)) *oanregistry.Config { + withDefaults := func(apply func(*sunbirdRegistry.Config)) *sunbirdRegistry.Config { cfg := defaultConfig() apply(cfg) return cfg @@ -35,13 +35,13 @@ func TestParseConfig(t *testing.T) { testCases := []struct { name string config map[string]string - expected *oanregistry.Config + expected *sunbirdRegistry.Config expectedErr string }{ { name: "applies defaults when only a URL is given", config: map[string]string{"url": "http://registry:8081/api/v1"}, - expected: withDefaults(func(c *oanregistry.Config) { c.URL = "http://registry:8081/api/v1" }), + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081/api/v1" }), }, { name: "reads every supported setting", @@ -54,7 +54,7 @@ func TestParseConfig(t *testing.T) { "retry_wait_min": "200ms", "retry_wait_max": "1s", }, - expected: &oanregistry.Config{ + expected: &sunbirdRegistry.Config{ URL: "http://registry:8081/api/v1", Entity: "Subscriber", ProviderEntity: defaultProviderEntity, @@ -71,7 +71,7 @@ func TestParseConfig(t *testing.T) { "url": "http://registry:8081", "providerEntity": "ProviderCapability", }, - expected: withDefaults(func(c *oanregistry.Config) { + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" c.ProviderEntity = "ProviderCapability" }), @@ -82,7 +82,7 @@ func TestParseConfig(t *testing.T) { "url": "http://registry:8081", "providerEntity": "", }, - expected: withDefaults(func(c *oanregistry.Config) { + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" }), }, @@ -91,18 +91,18 @@ func TestParseConfig(t *testing.T) { // participant keeps verifying. name: "leaves caching disabled when no TTL is set", config: map[string]string{"url": "http://registry:8081"}, - expected: withDefaults(func(c *oanregistry.Config) { c.URL = "http://registry:8081" }), + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" }), }, { // Distinct from "unset", which yields the default of 1. name: "honours an explicit retry_max of zero", config: map[string]string{"url": "http://registry:8081", "retry_max": "0"}, - expected: withDefaults(func(c *oanregistry.Config) { c.URL = "http://registry:8081"; c.RetryMax = 0 }), + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081"; c.RetryMax = 0 }), }, { name: "ignores empty values and keeps the defaults", config: map[string]string{"url": "http://registry:8081", "entity": "", "timeout": ""}, - expected: withDefaults(func(c *oanregistry.Config) { c.URL = "http://registry:8081" }), + expected: withDefaults(func(c *sunbirdRegistry.Config) { c.URL = "http://registry:8081" }), }, { name: "rejects a non-numeric timeout", @@ -144,7 +144,7 @@ func TestParseConfig(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, err := oanRegistryProvider{}.parseConfig(tc.config) + got, err := sunbirdRegistryProvider{}.parseConfig(tc.config) if tc.expectedErr != "" { if err == nil { @@ -173,7 +173,7 @@ func TestNew(t *testing.T) { t.Parallel() //nolint:staticcheck // deliberately passing a nil context to assert the guard. - _, _, err := oanRegistryProvider{}.New(nil, nil, map[string]string{"url": "http://registry:8081"}) + _, _, err := sunbirdRegistryProvider{}.New(nil, nil, map[string]string{"url": "http://registry:8081"}) if err == nil { t.Fatal("expected an error for a nil context, got none") } @@ -182,7 +182,7 @@ func TestNew(t *testing.T) { t.Run("rejects a missing URL", func(t *testing.T) { t.Parallel() - _, _, err := oanRegistryProvider{}.New(context.Background(), nil, map[string]string{}) + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{}) if err == nil { t.Fatal("expected an error for a missing URL, got none") } @@ -191,7 +191,7 @@ func TestNew(t *testing.T) { t.Run("rejects an unparseable config", func(t *testing.T) { t.Parallel() - _, _, err := oanRegistryProvider{}.New(context.Background(), nil, map[string]string{ + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{ "url": "http://registry:8081", "timeout": "soon", }) @@ -203,7 +203,7 @@ func TestNew(t *testing.T) { t.Run("builds a client from a valid config", func(t *testing.T) { t.Parallel() - client, closer, err := oanRegistryProvider{}.New(context.Background(), nil, map[string]string{ + client, closer, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{ "url": "http://registry:8081/api/v1", }) if err != nil { @@ -220,20 +220,20 @@ func TestNew(t *testing.T) { } }) - // Deliberately NOT parallel: this swaps the package-level newOANRegistryFunc, + // Deliberately NOT parallel: this swaps the package-level newSunbirdRegistryFunc, // so running it alongside its parallel siblings would race on that variable. // Go never schedules a non-parallel subtest concurrently with parallel ones, // which is what makes this safe -- do not add t.Parallel() "for consistency". t.Run("propagates a client construction failure", func(t *testing.T) { - original := newOANRegistryFunc - t.Cleanup(func() { newOANRegistryFunc = original }) + original := newSunbirdRegistryFunc + t.Cleanup(func() { newSunbirdRegistryFunc = original }) wantErr := errors.New("boom") - newOANRegistryFunc = func(context.Context, definition.Cache, *oanregistry.Config) (*oanregistry.Client, func() error, error) { + newSunbirdRegistryFunc = func(context.Context, definition.Cache, *sunbirdRegistry.Config) (*sunbirdRegistry.Client, func() error, error) { return nil, nil, wantErr } - _, _, err := oanRegistryProvider{}.New(context.Background(), nil, map[string]string{"url": "http://registry:8081"}) + _, _, err := sunbirdRegistryProvider{}.New(context.Background(), nil, map[string]string{"url": "http://registry:8081"}) if !errors.Is(err, wantErr) { t.Fatalf("expected the underlying error to be propagated, got: %v", err) } diff --git a/pkg/plugin/implementation/oanregistry/providerrecord.go b/pkg/plugin/implementation/sunbirdRegistry/providerrecord.go similarity index 98% rename from pkg/plugin/implementation/oanregistry/providerrecord.go rename to pkg/plugin/implementation/sunbirdRegistry/providerrecord.go index cf3dd5a6..03e621d0 100644 --- a/pkg/plugin/implementation/oanregistry/providerrecord.go +++ b/pkg/plugin/implementation/sunbirdRegistry/providerrecord.go @@ -1,10 +1,10 @@ -package oanregistry +package sunbirdRegistry // providerrecord.go resolves a capability binding into a call plan: what to // call, how to call it, and which mappings translate in and out. // -// This is the second thing the OAN registry is asked for, and it is a different -// question from the signing-key lookup in oanregistry.go. That one asks "who +// This is the second thing the registry is asked for, and it is a different +// question from the signing-key lookup in sunbirdRegistry.go. That one asks "who // sent this", keyed by an inbound Authorization header. This one asks "who do I // call next", keyed by a binding taken from the request body. Different subject, // different cache, different meaning of failure -- so they share transport and @@ -95,7 +95,7 @@ func searchURLFor(baseURL, entity string) string { func (c *Client) ProviderRecord(ctx context.Context, bindingKey string) (*model.ProviderRecord, error) { start := time.Now() tracer := otel.Tracer(telemetry.ScopeName, trace.WithInstrumentationVersion(telemetry.ScopeVersion)) - ctx, span := tracer.Start(ctx, "oan registry provider record") + ctx, span := tracer.Start(ctx, "registry provider record") defer span.End() if bindingKey == "" { @@ -373,7 +373,7 @@ func decodeRecords[T any](body []byte) ([]T, error) { // one cache but have different subjects and lifetimes, and a collision would // serve one as the other. func providerRecordCacheKey(bindingKey string) string { - return "oan_provider_" + bindingKey + return "registry_provider_" + bindingKey } func (c *Client) cachedProviderRecord(ctx context.Context, tracer trace.Tracer, key string) (*model.ProviderRecord, bool) { diff --git a/pkg/plugin/implementation/oanregistry/providerrecord_test.go b/pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go similarity index 99% rename from pkg/plugin/implementation/oanregistry/providerrecord_test.go rename to pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go index 2f83315f..aab2c4c8 100644 --- a/pkg/plugin/implementation/oanregistry/providerrecord_test.go +++ b/pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go @@ -1,4 +1,4 @@ -package oanregistry +package sunbirdRegistry import ( "context" @@ -591,7 +591,7 @@ func TestProviderRecordCacheKeyIsDistinctFromTheKeyLookupCacheKey(t *testing.T) if _, err := resolvePlan(t, client); err != nil { t.Fatalf("ProviderRecord() returned an unexpected error: %v", err) } - if strings.HasPrefix(cache.setKey, "oan_lookup_") { + if strings.HasPrefix(cache.setKey, "registry_lookup_") { t.Errorf("provider plan cache key %q shares the signing-key namespace", cache.setKey) } } diff --git a/pkg/plugin/implementation/oanregistry/oanregistry.go b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go similarity index 96% rename from pkg/plugin/implementation/oanregistry/oanregistry.go rename to pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go index 950829cc..0d598eb5 100644 --- a/pkg/plugin/implementation/oanregistry/oanregistry.go +++ b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go @@ -1,9 +1,9 @@ -// Package oanregistry resolves participant signing keys from the OAN Registry -// (a SunbirdRC deployment) so inbound Beckn signatures can be verified. +// Package sunbirdRegistry resolves participant signing keys from a SunbirdRC +// registry so inbound Beckn signatures can be verified. // // It implements definition.RegistryLookup only. Onboarding, key publication and // status changes all happen through the registry's own Participant APIs. -package oanregistry +package sunbirdRegistry import ( "context" @@ -129,13 +129,13 @@ func classify(err error) string { } const ( - pluginID = "oanregistry" + pluginID = "sunbirdRegistry" pluginType = "registry" operationLookup = "lookup" operationProviderRecord = "provider_record" ) -// Config holds configuration parameters for the OAN registry client. +// Config holds configuration parameters for the registry client. type Config struct { // URL is the registry base including any API version prefix, // e.g. "http://registry:8081/api/v1". @@ -156,7 +156,7 @@ type Config struct { MaxResponseBytes int64 `yaml:"maxResponseBytes" json:"maxResponseBytes"` } -// Client resolves participants from the OAN registry. It is safe for concurrent +// Client resolves participants from the registry. It is safe for concurrent // use: every field is set once in New and never mutated afterwards. type Client struct { searchURL string @@ -232,23 +232,23 @@ type searchRequest struct { Filters map[string]eqFilter `json:"filters"` } -// validate checks if the provided OAN registry configuration is valid. +// validate checks if the provided registry configuration is valid. func validate(cfg *Config) error { if cfg == nil { - return fmt.Errorf("oan registry config cannot be nil") + return fmt.Errorf("registry config cannot be nil") } if cfg.URL == "" { - return fmt.Errorf("oan registry URL cannot be empty") + return fmt.Errorf("registry URL cannot be empty") } // url.Parse accepts almost anything, so check the parts that actually have // to be there. Catching "registry:8081" (no scheme) at startup is far // cheaper than watching every lookup fail once traffic arrives. parsed, err := url.Parse(cfg.URL) if err != nil { - return fmt.Errorf("invalid oan registry URL %q: %w", cfg.URL, err) + return fmt.Errorf("invalid registry URL %q: %w", cfg.URL, err) } if parsed.Scheme == "" || parsed.Host == "" { - return fmt.Errorf("oan registry URL %q must include a scheme and host, e.g. http://:/api/v1", cfg.URL) + return fmt.Errorf("registry URL %q must include a scheme and host, e.g. http://:/api/v1", cfg.URL) } return nil } @@ -359,7 +359,7 @@ func New(ctx context.Context, cache definition.Cache, cfg *Config) (*Client, fun func (c *Client) Lookup(ctx context.Context, req *model.Subscription) ([]model.Subscription, error) { start := time.Now() tracer := otel.Tracer(telemetry.ScopeName, trace.WithInstrumentationVersion(telemetry.ScopeVersion)) - ctx, span := tracer.Start(ctx, "oan registry lookup") + ctx, span := tracer.Start(ctx, "registry lookup") defer span.End() // M2: an empty key id would match any record whose OSID is absent. Unreachable @@ -371,7 +371,7 @@ func (c *Client) Lookup(ctx context.Context, req *model.Subscription) ([]model.S return nil, nil } - cacheKey := fmt.Sprintf("oan_lookup_%s_%s", req.SubscriberID, req.KeyID) + cacheKey := fmt.Sprintf("registry_lookup_%s_%s", req.SubscriberID, req.KeyID) if cached, ok := c.cached(ctx, tracer, cacheKey); ok { log.Debugf(ctx, "OAN registry lookup cache hit for key: %s", cacheKey) span.SetAttributes(telemetry.AttrErrorType.String(outcomeCacheHit)) @@ -547,7 +547,7 @@ func toSubscription(p participant, k key, status string) model.Subscription { validFrom, _ := parseTime(k.ValidFrom) validUntil, _ := parseTime(k.ValidUntil) - // Domain is absent from the OAN record and so is left unset. Nothing on the + // Domain is absent from the registry record and so is left unset. Nothing on the // signature-validation path reads it. return model.Subscription{ Subscriber: model.Subscriber{ diff --git a/pkg/plugin/implementation/oanregistry/oanregistry_test.go b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go similarity index 99% rename from pkg/plugin/implementation/oanregistry/oanregistry_test.go rename to pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go index bc6f438a..ddbe9711 100644 --- a/pkg/plugin/implementation/oanregistry/oanregistry_test.go +++ b/pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go @@ -1,4 +1,4 @@ -package oanregistry +package sunbirdRegistry import ( "context" @@ -144,12 +144,12 @@ func TestValidate(t *testing.T) { { name: "should return error for nil config", config: nil, - expectedErr: "oan registry config cannot be nil", + expectedErr: "registry config cannot be nil", }, { name: "should return error for empty URL", config: &Config{URL: ""}, - expectedErr: "oan registry URL cannot be empty", + expectedErr: "registry URL cannot be empty", }, { name: "should succeed for valid config", @@ -1169,7 +1169,7 @@ func TestLookupCachesUsableResults(t *testing.T) { if cache.setTTL != ttl { t.Errorf("expected the configured TTL %v, got %v", ttl, cache.setTTL) } - if expected := fmt.Sprintf("oan_lookup_%s_%s", testParticipantID, testOSID); cache.setKey != expected { + if expected := fmt.Sprintf("registry_lookup_%s_%s", testParticipantID, testOSID); cache.setKey != expected { t.Errorf("expected cache key %q, got %q", expected, cache.setKey) } @@ -1358,7 +1358,7 @@ func assertOutcomeAttribute(t *testing.T, m metricdata.Metrics, outcome string) } // TestLookupAgainstCapturedRegistryResponse runs the plugin against a verbatim -// response captured from the real OAN registry on 31 Aug 2026, reformatted for +// response captured from a live registry on 31 Aug 2026, reformatted for // readability with field order and values untouched. // // It pins the deployed shape: the data envelope, one flat level with the keys @@ -1482,7 +1482,7 @@ func TestLookupAgainstCapturedRegistryResponse(t *testing.T) { } // TestLookupAgainstCurrentRegistryResponse runs the plugin against a verbatim -// response captured from an OAN registry on 2 Sep 2026, after the Participant +// response captured from a live registry on 2 Sep 2026, after the Participant // schema dropped three things from a published key. // // It pins the shape a registry writes TODAY, and every difference from the