From 912697bcd3ce0bbdc61d3942cdffac7d7c018177 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 14:10:56 +0930 Subject: [PATCH 1/3] refactor provider extension and tests --- .../provider/components/base_instance.ex | 1 + lib/diffo/provider/components/base_party.ex | 1 + lib/diffo/provider/components/base_place.ex | 1 + .../provider/components/instance/extension.ex | 412 +------------- .../components/instance/extension/info.ex | 21 +- .../provider/components/party/extension.ex | 114 +--- .../components/party/extension/info.ex | 18 +- .../provider/components/place/extension.ex | 115 +--- .../components/place/extension/info.ex | 18 +- lib/diffo/provider/extension.ex | 515 ++++++++++++++++++ lib/diffo/provider/extension/action_create.ex | 8 + lib/diffo/provider/extension/action_update.ex | 8 + .../provider/extension/characteristic.ex | 134 +++++ lib/diffo/provider/extension/feature.ex | 92 ++++ lib/diffo/provider/extension/info.ex | 30 + lib/diffo/provider/extension/instance_role.ex | 12 + .../provider/extension/party_declaration.ex | 20 + lib/diffo/provider/extension/party_role.ex | 12 + .../persisters/persist_characteristics.ex | 26 + .../extension/persisters/persist_features.ex | 26 + .../extension/persisters/persist_instances.ex | 26 + .../extension/persisters/persist_parties.ex | 26 + .../extension/persisters/persist_places.ex | 26 + .../persisters/persist_specification.ex | 41 ++ .../provider/extension/place_declaration.ex | 20 + lib/diffo/provider/extension/place_role.ex | 12 + .../transformers/transform_behaviour.ex | 124 +++++ .../extension/verifiers/verify_behaviour.ex | 66 +++ .../verifiers/verify_characteristics.ex | 60 ++ .../extension/verifiers/verify_features.ex | 79 +++ .../extension/verifiers/verify_instances.ex | 68 +++ .../extension/verifiers/verify_parties.ex | 68 +++ .../extension/verifiers/verify_places.ex | 68 +++ .../verifiers/verify_specification.ex | 94 ++++ .../extension}/assigner_test.exs | 2 +- .../extension}/characteristic_test.exs | 2 +- .../extension}/feature_test.exs | 2 +- .../extension/instance_transformer_test.exs} | 8 +- .../extension/instance_verifier_test.exs} | 50 +- .../extension}/party_test.exs | 4 +- .../extension/party_transformer_test.exs} | 8 +- .../extension/party_verifier_test.exs} | 62 ++- .../extension}/place_test.exs | 2 +- .../extension/place_transformer_test.exs} | 8 +- .../extension/place_verifier_test.exs} | 62 ++- .../extension}/specification_test.exs | 2 +- test/support/resource/broadband.ex | 10 +- test/support/resource/broadband_v2.ex | 10 +- test/support/resource/card.ex | 12 +- test/support/resource/carrier.ex | 12 +- test/support/resource/exchange_building.ex | 12 +- test/support/resource/geographic_site.ex | 18 +- test/support/resource/organization.ex | 18 +- test/support/resource/person.ex | 18 +- test/support/resource/shelf.ex | 16 +- 55 files changed, 1898 insertions(+), 802 deletions(-) create mode 100644 lib/diffo/provider/extension.ex create mode 100644 lib/diffo/provider/extension/action_create.ex create mode 100644 lib/diffo/provider/extension/action_update.ex create mode 100644 lib/diffo/provider/extension/characteristic.ex create mode 100644 lib/diffo/provider/extension/feature.ex create mode 100644 lib/diffo/provider/extension/info.ex create mode 100644 lib/diffo/provider/extension/instance_role.ex create mode 100644 lib/diffo/provider/extension/party_declaration.ex create mode 100644 lib/diffo/provider/extension/party_role.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_characteristics.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_features.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_instances.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_parties.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_places.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_specification.ex create mode 100644 lib/diffo/provider/extension/place_declaration.ex create mode 100644 lib/diffo/provider/extension/place_role.ex create mode 100644 lib/diffo/provider/extension/transformers/transform_behaviour.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_behaviour.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_characteristics.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_features.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_instances.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_parties.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_places.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_specification.ex rename test/{instance_extension => provider/extension}/assigner_test.exs (99%) rename test/{instance_extension => provider/extension}/characteristic_test.exs (92%) rename test/{instance_extension => provider/extension}/feature_test.exs (94%) rename test/{instance_extension/transformer_test.exs => provider/extension/instance_transformer_test.exs} (97%) rename test/{instance_extension/verifier_test.exs => provider/extension/instance_verifier_test.exs} (95%) rename test/{instance_extension => provider/extension}/party_test.exs (98%) rename test/{party_extension/transformer_test.exs => provider/extension/party_transformer_test.exs} (92%) rename test/{party_extension/verifier_test.exs => provider/extension/party_verifier_test.exs} (79%) rename test/{instance_extension => provider/extension}/place_test.exs (98%) rename test/{place_extension/transformer_test.exs => provider/extension/place_transformer_test.exs} (89%) rename test/{place_extension/verifier_test.exs => provider/extension/place_verifier_test.exs} (79%) rename test/{instance_extension => provider/extension}/specification_test.exs (96%) diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 463856b..94fa443 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -174,6 +174,7 @@ defmodule Diffo.Provider.BaseInstance do AshOutstanding.Resource, AshJason.Resource, AshStateMachine, + Diffo.Provider.Extension, Diffo.Provider.Instance.Extension ] diff --git a/lib/diffo/provider/components/base_party.ex b/lib/diffo/provider/components/base_party.ex index 5840ecc..054fa2d 100644 --- a/lib/diffo/provider/components/base_party.ex +++ b/lib/diffo/provider/components/base_party.ex @@ -120,6 +120,7 @@ defmodule Diffo.Provider.BaseParty do extensions: [ AshOutstanding.Resource, AshJason.Resource, + Diffo.Provider.Extension, Diffo.Provider.Party.Extension ] diff --git a/lib/diffo/provider/components/base_place.ex b/lib/diffo/provider/components/base_place.ex index f29005c..a9f5a74 100644 --- a/lib/diffo/provider/components/base_place.ex +++ b/lib/diffo/provider/components/base_place.ex @@ -93,6 +93,7 @@ defmodule Diffo.Provider.BasePlace do extensions: [ AshOutstanding.Resource, AshJason.Resource, + Diffo.Provider.Extension, Diffo.Provider.Place.Extension ] diff --git a/lib/diffo/provider/components/instance/extension.ex b/lib/diffo/provider/components/instance/extension.ex index 9fc716c..6dfabdb 100644 --- a/lib/diffo/provider/components/instance/extension.ex +++ b/lib/diffo/provider/components/instance/extension.ex @@ -3,414 +3,6 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Instance.Extension do - @moduledoc """ - DSL Extension customising an Instance. - - Provides two top-level sections: - - ## structure - - Describes the static shape of the Instance kind — what it is, what values it carries, - and what parties it relates to. All structure declarations are baked into the resource - module at compile time via persisters and are introspectable at runtime via - `Diffo.Provider.Instance.Info` or directly as generated functions on the resource module. - - - `specification do` — the TMF Specification (id, name, type, version, description, category). - The id is a stable UUID4 that is the same across all environments for this Instance kind. - - `characteristics do` — typed value slots carried by instances of this kind, each backed - by an `Ash.TypedStruct`. - - `features do` — optional capabilities of this kind, each with its own typed characteristic - payload and an enabled/disabled default. - - `parties do` — the party roles that instances of this kind relate to, with multiplicity, - reference, and calculation options. - - `places do` — the place roles that instances of this kind relate to, mirroring `parties do` - in structure and options. - - ## behaviour - - Declares which Ash actions should be wired for instance build lifecycle management. - Currently supports `create` declarations; future sections will cover triggers and other - lifecycle concerns. - - Declaring `create :name` in `behaviour do actions do` causes the `TransformBehaviour` - transformer to inject `:specified_by`, `:features`, and `:characteristics` arguments onto - the named Ash create action. These arguments carry the UUIDs of the TMF entities created - by `build_before/1` and consumed by the Ash relationship management in the action. - - See the [DSL cheat sheet](DSL-Diffo.Provider.Instance.Extension.html) for the full DSL reference. - See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. - """ - - # ── structure ────────────────────────────────────────────────────────────── - - @specification %Spark.Dsl.Section{ - name: :specification, - describe: "Defines the Instance Specification", - examples: [ - """ - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - major_version 1 - description "An access network service connecting a subscriber premises to an access NNI via DSL" - category "Network Service" - end - """ - ], - schema: [ - id: [ - type: :string, - doc: - "The id of the specification, a uuid4 the same in all environments, unique for name and major_version.", - required: true - ], - name: [ - type: :string, - doc: "The name of the specification, unique to a service but common for all versions.", - required: true - ], - type: [ - type: :atom, - doc: "The type of the specification.", - default: :serviceSpecification - ], - major_version: [ - type: :integer, - doc: "The major_version of the specification.", - default: 1 - ], - minor_version: [ - type: :integer, - doc: "The minor_version of the specification." - ], - patch_version: [ - type: :integer, - doc: "The patch_version of the specification." - ], - tmf_version: [ - type: :integer, - doc: "The TMF API version of the specification, e.g. 4." - ], - description: [ - type: :string, - doc: "A generic description of the specified service or resource." - ], - category: [ - type: :string, - doc: "The category the specified service or resource belongs to." - ] - ] - } - - @characteristic %Spark.Dsl.Entity{ - name: :characteristic, - describe: "Adds a Characteristic", - target: Diffo.Provider.Instance.Characteristic, - args: [:name, :value_type], - schema: [ - name: [ - doc: "The name of the characteristic, an atom", - type: :atom, - required: true - ], - value_type: [ - doc: - "The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type.", - type: :any - ] - ] - } - - @characteristics %Spark.Dsl.Section{ - name: :characteristics, - describe: "List of Instance Characteristics", - examples: [ - """ - characteristics do - characteristic :dslam, Diffo.Access.Dslam - characteristic :aggregate_interface, Diffo.Access.AggregateInterface - characteristic :circuit, Diffo.Access.Circuit - characteristic :line, Diffo.Access.Line - end - """ - ], - entities: [@characteristic] - } - - @feature %Spark.Dsl.Entity{ - name: :feature, - describe: "Adds a Feature", - target: Diffo.Provider.Instance.Feature, - args: [:name], - schema: [ - name: [ - doc: "The name of the feature, an atom", - type: :atom, - required: true - ], - is_enabled?: [ - doc: "Whether the feature is enabled by default, defaults true", - type: :boolean - ] - ], - entities: [ - characteristics: [@characteristic] - ] - } - - @features %Spark.Dsl.Section{ - name: :features, - describe: "Configuration for Instance Features", - examples: [ - """ - features do - feature :dynamic_line_management do - is_enabled? true - characteristics do - characteristic :constraints, Diffo.Access.Constraints - end - end - end - """ - ], - entities: [@feature] - } - - @party_schema [ - role: [ - doc: "The role name, an atom", - type: :atom, - required: true - ], - party_type: [ - doc: - "The module of the Party kind. An atom module name such as a BaseParty-derived resource.", - type: :any - ], - reference: [ - doc: - "If true, no direct PartyRef edge is created; the party is reachable by graph traversal.", - type: :boolean, - default: false - ], - calculate: [ - doc: "Name of an Ash calculation on this resource that produces the party at build time.", - type: :atom - ] - ] - - @party_entity %Spark.Dsl.Entity{ - name: :party, - describe: "Declares a singular party role on this Instance", - target: Diffo.Provider.Instance.Extension.PartyDeclaration, - args: [:role, :party_type], - auto_set_fields: [multiple: false], - schema: @party_schema - } - - @parties_entity %Spark.Dsl.Entity{ - name: :parties, - describe: "Declares a plural party role on this Instance", - target: Diffo.Provider.Instance.Extension.PartyDeclaration, - args: [:role, :party_type], - auto_set_fields: [multiple: true], - schema: - @party_schema ++ - [ - constraints: [ - doc: - "Multiplicity constraints on the number of parties in this role, e.g. [min: 1, max: 3]", - type: :keyword_list - ] - ] - } - - @parties %Spark.Dsl.Section{ - name: :parties, - describe: "List of Instance Party roles", - examples: [ - """ - parties do - party :provider, MyApp.Provider, calculate: :provider_calculation - parties :technician, MyApp.Technician, constraints: [min: 1, max: 3] - party :owner, MyApp.InfrastructureCo, reference: true - end - """ - ], - entities: [@party_entity, @parties_entity] - } - - @place_schema [ - role: [ - doc: "The role name, an atom", - type: :atom, - required: true - ], - place_type: [ - doc: "The module of the Place kind. A BasePlace-derived resource.", - type: :any - ], - reference: [ - doc: - "If true, no direct PlaceRef edge is created; the place is reachable by graph traversal.", - type: :boolean, - default: false - ], - calculate: [ - doc: "Name of an Ash calculation on this resource that produces the place at build time.", - type: :atom - ] - ] - - @place_entity %Spark.Dsl.Entity{ - name: :place, - describe: "Declares a singular place role on this Instance", - target: Diffo.Provider.Instance.Extension.PlaceDeclaration, - args: [:role, :place_type], - auto_set_fields: [multiple: false], - schema: @place_schema - } - - @places_entity %Spark.Dsl.Entity{ - name: :places, - describe: "Declares a plural place role on this Instance", - target: Diffo.Provider.Instance.Extension.PlaceDeclaration, - args: [:role, :place_type], - auto_set_fields: [multiple: true], - schema: - @place_schema ++ - [ - constraints: [ - doc: - "Multiplicity constraints on the number of places in this role, e.g. [min: 1, max: 3]", - type: :keyword_list - ] - ] - } - - @places %Spark.Dsl.Section{ - name: :places, - describe: "List of Instance Place roles", - examples: [ - """ - places do - place :installation_site, MyApp.GeographicSite - places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] - place :billing_address, MyApp.GeographicAddress, reference: true - end - """ - ], - entities: [@place_entity, @places_entity] - } - - @structure %Spark.Dsl.Section{ - name: :structure, - describe: - "Defines the structural shape of the Instance — its specification, characteristics, features, parties, and places", - examples: [ - """ - structure do - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - end - - characteristics do - characteristic :circuit, Diffo.Access.Circuit - end - - parties do - party :provider, MyApp.Provider - end - - places do - place :installation_site, MyApp.GeographicSite - end - end - """ - ], - sections: [@specification, @characteristics, @features, @parties, @places] - } - - # ── behaviour ────────────────────────────────────────────────────────────── - - @action_create %Spark.Dsl.Entity{ - name: :create, - describe: - "Marks a create action for instance build wiring, injecting :specified_by, :features, and :characteristics arguments", - target: Diffo.Provider.Instance.Extension.ActionCreate, - args: [:name], - schema: [ - name: [ - type: :atom, - required: true, - doc: "The name of the create action to wire" - ] - ] - } - - @action_update %Spark.Dsl.Entity{ - name: :update, - describe: "Marks an update action for instance behaviour wiring", - target: Diffo.Provider.Instance.Extension.ActionUpdate, - args: [:name], - schema: [ - name: [ - type: :atom, - required: true, - doc: "The name of the update action to wire" - ] - ] - } - - @behaviour_actions %Spark.Dsl.Section{ - name: :actions, - describe: "Declares which actions to wire for instance behaviour", - examples: [ - """ - actions do - create :build - update :define - end - """ - ], - entities: [@action_create, @action_update] - } - - @behaviour_section %Spark.Dsl.Section{ - name: :behaviour, - describe: - "Defines the behavioural wiring for the Instance — actions, and in future triggers and tasks", - examples: [ - """ - behaviour do - actions do - create :build - update :define - end - end - """ - ], - sections: [@behaviour_actions] - } - - use Spark.Dsl.Extension, - sections: [@structure, @behaviour_section], - persisters: [ - Diffo.Provider.Instance.Extension.Persisters.PersistSpecification, - Diffo.Provider.Instance.Extension.Persisters.PersistCharacteristics, - Diffo.Provider.Instance.Extension.Persisters.PersistFeatures, - Diffo.Provider.Instance.Extension.Persisters.PersistParties, - Diffo.Provider.Instance.Extension.Persisters.PersistPlaces, - Diffo.Provider.Instance.Extension.Transformers.TransformBehaviour - ], - verifiers: [ - Diffo.Provider.Instance.Extension.Verifiers.VerifySpecification, - Diffo.Provider.Instance.Extension.Verifiers.VerifyCharacteristics, - Diffo.Provider.Instance.Extension.Verifiers.VerifyFeatures, - Diffo.Provider.Instance.Extension.Verifiers.VerifyParties, - Diffo.Provider.Instance.Extension.Verifiers.VerifyBehaviour - ] + @moduledoc "Marker extension — identifies BaseInstance-derived resources. DSL is in `Diffo.Provider.Extension`." + use Spark.Dsl.Extension, sections: [] end diff --git a/lib/diffo/provider/components/instance/extension/info.ex b/lib/diffo/provider/components/instance/extension/info.ex index 3356537..e43f86a 100644 --- a/lib/diffo/provider/components/instance/extension/info.ex +++ b/lib/diffo/provider/components/instance/extension/info.ex @@ -3,14 +3,21 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Instance.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Instance.Extension, - sections: [:structure] + alias Diffo.Provider.Extension.Info, as: ExtInfo @doc "Returns true if the module is a BaseInstance-derived resource" @spec instance?(module()) :: boolean() - def instance?(module) do - Code.ensure_loaded?(module) and - Diffo.Provider.Instance.Extension in Ash.Resource.Info.extensions(module) - end + defdelegate instance?(module), to: ExtInfo + + @doc false + defdelegate structure_parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate structure_places(module), to: ExtInfo, as: :provider_places + + @doc false + defdelegate structure_characteristics(module), to: ExtInfo, as: :provider_characteristics + + @doc false + defdelegate structure_features(module), to: ExtInfo, as: :provider_features end diff --git a/lib/diffo/provider/components/party/extension.ex b/lib/diffo/provider/components/party/extension.ex index a205402..d76fd37 100644 --- a/lib/diffo/provider/components/party/extension.ex +++ b/lib/diffo/provider/components/party/extension.ex @@ -3,116 +3,6 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Party.Extension do - @moduledoc """ - DSL Extension customising a Party. - - Provides compile-time declaration blocks for domain-specific Party kinds - built on `Diffo.Provider.BaseParty`. All declarations are introspectable via - `Diffo.Provider.Party.Extension.Info`. - - See the [DSL cheat sheet](DSL-Diffo.Provider.Party.Extension.html) for the full DSL reference. - """ - @role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Party kind plays", - target: Diffo.Provider.Party.Extension.InstanceRole, - args: [:role, :party_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - party_type: [ - type: :any, - doc: "The module of the related resource" - ] - ] - } - - @party_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Party kind plays with respect to other Parties", - target: Diffo.Provider.Party.Extension.PartyRole, - args: [:role, :party_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - party_type: [ - type: :any, - doc: "The module of the related Party kind" - ] - ] - } - - @instances %Spark.Dsl.Section{ - name: :instances, - describe: "Declares the roles this Party kind plays with respect to Instances", - examples: [ - """ - instances do - role :facilitates, MyApp.AccessService - end - """ - ], - entities: [@role] - } - - @parties %Spark.Dsl.Section{ - name: :parties, - describe: "Declares the roles this Party kind plays with respect to other Parties", - examples: [ - """ - parties do - role :managed_by, MyApp.Person - end - """ - ], - entities: [@party_role] - } - - @place_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Party kind plays with respect to Places", - target: Diffo.Provider.Party.Extension.PlaceRole, - args: [:role, :place_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - place_type: [ - type: :any, - doc: "The module of the related Place resource" - ] - ] - } - - @places %Spark.Dsl.Section{ - name: :places, - describe: "Declares the roles this Party kind plays with respect to Places", - examples: [ - """ - places do - role :headquartered_at, MyApp.GeographicSite - end - """ - ], - entities: [@place_role] - } - - use Spark.Dsl.Extension, - sections: [@instances, @parties, @places], - persisters: [ - Diffo.Provider.Party.Extension.Persisters.PersistInstances, - Diffo.Provider.Party.Extension.Persisters.PersistParties, - Diffo.Provider.Party.Extension.Persisters.PersistPlaces - ], - verifiers: [ - Diffo.Provider.Party.Extension.Verifiers.VerifyRoles - ] + @moduledoc "Marker extension — identifies BaseParty-derived resources. DSL is in `Diffo.Provider.Extension`." + use Spark.Dsl.Extension, sections: [] end diff --git a/lib/diffo/provider/components/party/extension/info.ex b/lib/diffo/provider/components/party/extension/info.ex index 2ca0532..bdf1d9b 100644 --- a/lib/diffo/provider/components/party/extension/info.ex +++ b/lib/diffo/provider/components/party/extension/info.ex @@ -3,14 +3,18 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Party.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Party.Extension, - sections: [:instances, :parties, :places] + alias Diffo.Provider.Extension.Info, as: ExtInfo @doc "Returns true if the module is a BaseParty-derived resource" @spec party?(module()) :: boolean() - def party?(module) do - Code.ensure_loaded?(module) and - Diffo.Provider.Party.Extension in Ash.Resource.Info.extensions(module) - end + defdelegate party?(module), to: ExtInfo + + @doc false + defdelegate instances(module), to: ExtInfo, as: :provider_instances + + @doc false + defdelegate parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate places(module), to: ExtInfo, as: :provider_places end diff --git a/lib/diffo/provider/components/place/extension.ex b/lib/diffo/provider/components/place/extension.ex index 74df77a..ea07279 100644 --- a/lib/diffo/provider/components/place/extension.ex +++ b/lib/diffo/provider/components/place/extension.ex @@ -3,117 +3,6 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Place.Extension do - @moduledoc """ - DSL Extension customising a Place. - - Provides compile-time declaration blocks for domain-specific Place kinds - built on `Diffo.Provider.BasePlace`. All declarations are introspectable via - `Diffo.Provider.Place.Extension.Info`. - - See the [DSL cheat sheet](DSL-Diffo.Provider.Place.Extension.html) for the full DSL reference. - See `Diffo.Provider.BasePlace` for full usage documentation. - """ - @instance_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Place kind plays with respect to Instances", - target: Diffo.Provider.Place.Extension.InstanceRole, - args: [:role, :instance_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - instance_type: [ - type: :any, - doc: "The module of the related Instance resource" - ] - ] - } - - @party_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Place kind plays with respect to Parties", - target: Diffo.Provider.Place.Extension.PartyRole, - args: [:role, :party_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - party_type: [ - type: :any, - doc: "The module of the related Party resource" - ] - ] - } - - @place_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Place kind plays with respect to other Places", - target: Diffo.Provider.Place.Extension.PlaceRole, - args: [:role, :place_type], - schema: [ - role: [ - type: :atom, - required: true, - doc: "The role name, an atom" - ], - place_type: [ - type: :any, - doc: "The module of the related Place resource" - ] - ] - } - - @instances %Spark.Dsl.Section{ - name: :instances, - describe: "Declares the roles this Place kind plays with respect to Instances", - examples: [ - """ - instances do - role :site_for, MyApp.AccessService - end - """ - ], - entities: [@instance_role] - } - - @parties %Spark.Dsl.Section{ - name: :parties, - describe: "Declares the roles this Place kind plays with respect to Parties", - examples: [ - """ - parties do - role :home_of, MyApp.Organization - end - """ - ], - entities: [@party_role] - } - - @places %Spark.Dsl.Section{ - name: :places, - describe: "Declares the roles this Place kind plays with respect to other Places", - examples: [ - """ - places do - role :within, MyApp.GeographicSite - end - """ - ], - entities: [@place_role] - } - - use Spark.Dsl.Extension, - sections: [@instances, @parties, @places], - persisters: [ - Diffo.Provider.Place.Extension.Persisters.PersistInstances, - Diffo.Provider.Place.Extension.Persisters.PersistParties, - Diffo.Provider.Place.Extension.Persisters.PersistPlaces - ], - verifiers: [ - Diffo.Provider.Place.Extension.Verifiers.VerifyRoles - ] + @moduledoc "Marker extension — identifies BasePlace-derived resources. DSL is in `Diffo.Provider.Extension`." + use Spark.Dsl.Extension, sections: [] end diff --git a/lib/diffo/provider/components/place/extension/info.ex b/lib/diffo/provider/components/place/extension/info.ex index 4023595..d319d70 100644 --- a/lib/diffo/provider/components/place/extension/info.ex +++ b/lib/diffo/provider/components/place/extension/info.ex @@ -3,14 +3,18 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Place.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Place.Extension, - sections: [:instances, :parties, :places] + alias Diffo.Provider.Extension.Info, as: ExtInfo @doc "Returns true if the module is a BasePlace-derived resource" @spec place?(module()) :: boolean() - def place?(module) do - Code.ensure_loaded?(module) and - Diffo.Provider.Place.Extension in Ash.Resource.Info.extensions(module) - end + defdelegate place?(module), to: ExtInfo + + @doc false + defdelegate instances(module), to: ExtInfo, as: :provider_instances + + @doc false + defdelegate parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate places(module), to: ExtInfo, as: :provider_places end diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex new file mode 100644 index 0000000..bbc7042 --- /dev/null +++ b/lib/diffo/provider/extension.ex @@ -0,0 +1,515 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension do + @moduledoc """ + Unified DSL extension for all Diffo provider resource kinds. + + Provides a single `provider do` section for Instance, Party, and Place kinds. + The sections within `provider do` are self-similar across kinds — each kind uses + the sections relevant to it, and verifiers enforce correct usage. + + ## Instance + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + end + + characteristics do + characteristic :circuit, Diffo.Access.Circuit + end + + features do + feature :dynamic_line_management do + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build + end + end + end + + ## Party + + provider do + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + parties do + role :employer, MyApp.Person + end + places do + role :headquarters, MyApp.GeographicSite + end + end + + ## Place + + provider do + instances do + role :site_for, MyApp.AccessService + end + parties do + role :managed_by, MyApp.Organization + end + places do + role :within, MyApp.GeographicSite + end + end + + See `Diffo.Provider.Extension.Info` for runtime introspection. + See `Diffo.Provider.BaseInstance`, `Diffo.Provider.BaseParty`, `Diffo.Provider.BasePlace` + for full usage documentation. + """ + + alias Diffo.Provider.Extension.{ + ActionCreate, + ActionUpdate, + Characteristic, + Feature, + InstanceRole, + PartyDeclaration, + PartyRole, + PlaceDeclaration, + PlaceRole + } + + # ── specification ────────────────────────────────────────────────────────── + + @specification %Spark.Dsl.Section{ + name: :specification, + describe: "Defines the Instance Specification", + examples: [ + """ + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + major_version 1 + description "An access network service" + category "Network Service" + end + """ + ], + schema: [ + id: [ + type: :string, + doc: + "The id of the specification, a uuid4 the same in all environments, unique for name and major_version.", + required: true + ], + name: [ + type: :string, + doc: "The name of the specification.", + required: true + ], + type: [ + type: :atom, + doc: "The type of the specification.", + default: :serviceSpecification + ], + major_version: [ + type: :integer, + doc: "The major version of the specification.", + default: 1 + ], + minor_version: [ + type: :integer, + doc: "The minor version of the specification." + ], + patch_version: [ + type: :integer, + doc: "The patch version of the specification." + ], + tmf_version: [ + type: :integer, + doc: "The TMF API version of the specification, e.g. 4." + ], + description: [ + type: :string, + doc: "A generic description of the specified service or resource." + ], + category: [ + type: :string, + doc: "The category the specified service or resource belongs to." + ] + ] + } + + # ── characteristics ──────────────────────────────────────────────────────── + + @characteristic_entity %Spark.Dsl.Entity{ + name: :characteristic, + describe: "Adds a Characteristic", + target: Characteristic, + args: [:name, :value_type], + schema: [ + name: [ + type: :atom, + doc: "The name of the characteristic.", + required: true + ], + value_type: [ + type: :any, + doc: + "The type of the characteristic value — a module or `{:array, module}` for an array." + ] + ] + } + + @characteristics %Spark.Dsl.Section{ + name: :characteristics, + describe: "List of Instance Characteristics", + examples: [ + """ + characteristics do + characteristic :circuit, Diffo.Access.Circuit + characteristic :line, Diffo.Access.Line + end + """ + ], + entities: [@characteristic_entity] + } + + # ── features ─────────────────────────────────────────────────────────────── + + @feature_entity %Spark.Dsl.Entity{ + name: :feature, + describe: "Adds a Feature", + target: Feature, + args: [:name], + schema: [ + name: [ + type: :atom, + doc: "The name of the feature.", + required: true + ], + is_enabled?: [ + type: :boolean, + doc: "Whether the feature is enabled by default, defaults true." + ] + ], + entities: [ + characteristics: [@characteristic_entity] + ] + } + + @features %Spark.Dsl.Section{ + name: :features, + describe: "Configuration for Instance Features", + examples: [ + """ + features do + feature :dynamic_line_management do + is_enabled? true + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + """ + ], + entities: [@feature_entity] + } + + # ── parties ──────────────────────────────────────────────────────────────── + + @party_schema [ + role: [type: :atom, doc: "The role name.", required: true], + party_type: [type: :any, doc: "The module of the Party kind."], + calculate: [type: :atom, doc: "Ash calculation on this resource that produces the party."] + ] + + @party_entity %Spark.Dsl.Entity{ + name: :party, + describe: "Declares a singular party role on this Instance", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: false, reference: false], + schema: @party_schema + } + + @parties_entity %Spark.Dsl.Entity{ + name: :parties, + describe: "Declares a plural party role on this Instance", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: true, reference: false], + schema: + @party_schema ++ + [ + constraints: [ + type: :keyword_list, + doc: "Multiplicity constraints, e.g. [min: 1, max: 3]." + ] + ] + } + + @party_ref_entity %Spark.Dsl.Entity{ + name: :party_ref, + describe: + "Declares a singular reference party role — no direct PartyRef edge, reachable by graph traversal", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: false, reference: true], + schema: @party_schema + } + + @party_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to other Parties", + target: PartyRole, + args: [:role, :party_type], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + party_type: [type: :any, doc: "The module of the related Party kind."] + ] + } + + @parties %Spark.Dsl.Section{ + name: :parties, + describe: + "Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds", + examples: [ + """ + # Instance + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + # Party or Place + parties do + role :employer, MyApp.Person + end + """ + ], + entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity] + } + + # ── places ───────────────────────────────────────────────────────────────── + + @place_schema [ + role: [type: :atom, doc: "The role name.", required: true], + place_type: [type: :any, doc: "The module of the Place kind."], + calculate: [type: :atom, doc: "Ash calculation on this resource that produces the place."] + ] + + @place_entity %Spark.Dsl.Entity{ + name: :place, + describe: "Declares a singular place role on this Instance", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: false, reference: false], + schema: @place_schema + } + + @places_entity %Spark.Dsl.Entity{ + name: :places, + describe: "Declares a plural place role on this Instance", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: true, reference: false], + schema: + @place_schema ++ + [ + constraints: [ + type: :keyword_list, + doc: "Multiplicity constraints, e.g. [min: 1, max: 3]." + ] + ] + } + + @place_ref_entity %Spark.Dsl.Entity{ + name: :place_ref, + describe: + "Declares a singular reference place role — no direct PlaceRef edge, reachable by graph traversal", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: false, reference: true], + schema: @place_schema + } + + @place_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to Places", + target: PlaceRole, + args: [:role, :place_type], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + place_type: [type: :any, doc: "The module of the related Place kind."] + ] + } + + @places %Spark.Dsl.Section{ + name: :places, + describe: + "Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds", + examples: [ + """ + # Instance + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + # Party or Place + places do + role :headquarters, MyApp.GeographicSite + end + """ + ], + entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity] + } + + # ── instances ────────────────────────────────────────────────────────────── + + @instance_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to Instances", + target: InstanceRole, + args: [:role, :instance_type], + auto_set_fields: [reference: false], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + instance_type: [type: :any, doc: "The module of the related Instance kind."] + ] + } + + @instance_ref_entity %Spark.Dsl.Entity{ + name: :instance_ref, + describe: + "Declares a reference instance role — no direct edge created, reachable by graph traversal", + target: InstanceRole, + args: [:role, :instance_type], + auto_set_fields: [reference: true], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + instance_type: [type: :any, doc: "The module of the related Instance kind."] + ] + } + + @instances %Spark.Dsl.Section{ + name: :instances, + describe: "Declares the roles this Party or Place kind plays with respect to Instances", + examples: [ + """ + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + """ + ], + entities: [@instance_role_entity, @instance_ref_entity] + } + + # ── behaviour ────────────────────────────────────────────────────────────── + + @action_create_entity %Spark.Dsl.Entity{ + name: :create, + describe: "Marks a create action for instance build wiring", + target: ActionCreate, + args: [:name], + schema: [ + name: [type: :atom, doc: "The name of the create action to wire.", required: true] + ] + } + + @action_update_entity %Spark.Dsl.Entity{ + name: :update, + describe: "Marks an update action for instance behaviour wiring", + target: ActionUpdate, + args: [:name], + schema: [ + name: [type: :atom, doc: "The name of the update action to wire.", required: true] + ] + } + + @behaviour_actions %Spark.Dsl.Section{ + name: :actions, + describe: "Declares which actions to wire for instance behaviour", + examples: [ + """ + actions do + create :build + update :define + end + """ + ], + entities: [@action_create_entity, @action_update_entity] + } + + @behaviour_section %Spark.Dsl.Section{ + name: :behaviour, + describe: "Defines the behavioural wiring for the Instance — actions, and in future triggers", + examples: [ + """ + behaviour do + actions do + create :build + end + end + """ + ], + sections: [@behaviour_actions] + } + + # ── provider (top-level wrapper) ─────────────────────────────────────────── + + @provider %Spark.Dsl.Section{ + name: :provider, + describe: "Provider DSL — structure, roles, and behaviour for this resource kind", + sections: [ + @specification, + @characteristics, + @features, + @parties, + @places, + @instances, + @behaviour_section + ] + } + + use Spark.Dsl.Extension, + sections: [@provider], + persisters: [ + Diffo.Provider.Extension.Persisters.PersistSpecification, + Diffo.Provider.Extension.Persisters.PersistCharacteristics, + Diffo.Provider.Extension.Persisters.PersistFeatures, + Diffo.Provider.Extension.Persisters.PersistParties, + Diffo.Provider.Extension.Persisters.PersistPlaces, + Diffo.Provider.Extension.Persisters.PersistInstances, + Diffo.Provider.Extension.Transformers.TransformBehaviour + ], + verifiers: [ + Diffo.Provider.Extension.Verifiers.VerifySpecification, + Diffo.Provider.Extension.Verifiers.VerifyCharacteristics, + Diffo.Provider.Extension.Verifiers.VerifyFeatures, + Diffo.Provider.Extension.Verifiers.VerifyParties, + Diffo.Provider.Extension.Verifiers.VerifyPlaces, + Diffo.Provider.Extension.Verifiers.VerifyInstances, + Diffo.Provider.Extension.Verifiers.VerifyBehaviour + ] +end diff --git a/lib/diffo/provider/extension/action_create.ex b/lib/diffo/provider/extension/action_create.ex new file mode 100644 index 0000000..a6eb7ab --- /dev/null +++ b/lib/diffo/provider/extension/action_create.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.ActionCreate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/extension/action_update.ex b/lib/diffo/provider/extension/action_update.ex new file mode 100644 index 0000000..6453f76 --- /dev/null +++ b/lib/diffo/provider/extension/action_update.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.ActionUpdate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex new file mode 100644 index 0000000..f931331 --- /dev/null +++ b/lib/diffo/provider/extension/characteristic.ex @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Characteristic do + @moduledoc false + require Logger + + alias Diffo.Provider + alias Diffo.Provider.Instance + alias Diffo.Type.Value + + defstruct [:name, :value_type, __spark_metadata__: nil] + + def set_characteristics_argument(changeset, declarations) + when is_struct(changeset, Ash.Changeset) and is_list(declarations) do + case characteristics = create_characteristics_from_declarations(declarations, :instance) do + [] -> + changeset + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) + + _ -> + characteristic_ids = Enum.map(characteristics, &Map.get(&1, :id)) + Ash.Changeset.force_set_argument(changeset, :characteristics, characteristic_ids) + end + end + + defp create_characteristics_from_declarations(declarations, type) do + Enum.reduce_while(declarations, [], fn %{name: name, value_type: value_type}, acc -> + try do + attrs = + case value_type do + {:array, _inner} -> + %{name: name, type: type, values: [], is_array: true} + + module -> + %{name: name, type: type, value: Value.dynamic(struct(module))} + end + + case Provider.create_characteristic(attrs) do + {:ok, result} -> + {:cont, [result | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + rescue + _e in UndefinedFunctionError -> + {:halt, + {:error, "couldn't create characteristic with value of unknown type #{value_type}"}} + end + end) + end + + def relate_instance(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + characteristics = Ash.Changeset.get_argument(changeset, :characteristics) + + Provider.relate_instance_characteristics(%Instance{id: result.id}, %{ + characteristics: characteristics + }) + end + + def update_values(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + characteristic_value_updates = + Ash.Changeset.get_argument(changeset, :characteristic_value_updates) + + case characteristic_value_updates do + nil -> + {:ok, result} + + [] -> + {:ok, result} + + _ -> + characteristic_updates = + Enum.reduce(characteristic_value_updates, [], fn {name, update}, acc -> + characteristic = + Enum.find(changeset.data.characteristics, fn %{name: n} -> n == name end) + + if characteristic do + cond do + is_list(update) -> + unwrapped = Diffo.Unwrap.unwrap(characteristic.value) + value_type = unwrapped.__struct__ + + updated = + Enum.reduce(update, unwrapped, fn {field, val}, acc -> + Map.put(acc, field, val) + end) + + new_value = Value.dynamic(struct(value_type, Map.from_struct(updated))) + [{characteristic, new_value} | acc] + + true -> + [{characteristic, update} | acc] + end + else + Logger.warning("couldn't find characteristic #{name}") + acc + end + end) + + characteristics = + Enum.reduce_while(characteristic_updates, [], fn {characteristic, value}, acc -> + case Provider.update_characteristic(characteristic, %{value: value}) do + {:ok, characteristic} -> + {:cont, [characteristic | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + end) + + case characteristics do + {:error, error} -> + {:error, error} + + [] -> + {:error, "couldn't update characteristics"} + + _ -> + {:ok, Map.put(result, :characteristics, characteristics)} + end + end + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/feature.ex b/lib/diffo/provider/extension/feature.ex new file mode 100644 index 0000000..38b5578 --- /dev/null +++ b/lib/diffo/provider/extension/feature.ex @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Feature do + @moduledoc false + require Logger + + alias Diffo.Provider + alias Diffo.Provider.Instance + alias Diffo.Type.Value + + defstruct [:name, :is_enabled?, :characteristics, __spark_metadata__: nil] + + def set_features_argument(changeset, declarations) + when is_struct(changeset, Ash.Changeset) and is_list(declarations) do + case features = create_features_from_declarations(declarations) do + [] -> + changeset + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) + + _ -> + feature_ids = Enum.map(features, &Map.get(&1, :id)) + Ash.Changeset.force_set_argument(changeset, :features, feature_ids) + end + end + + defp create_features_from_declarations(declarations) do + Enum.reduce_while( + declarations, + [], + fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, acc -> + characteristic_ids = + Enum.reduce_while(characteristics, [], fn %{name: name, value_type: value_type}, acc -> + try do + attrs = + case value_type do + {:array, _inner} -> + %{name: name, type: :feature, values: [], is_array: true} + + module -> + %{name: name, type: :feature, value: Value.dynamic(struct(module))} + end + + case Provider.create_characteristic(attrs) do + {:ok, result} -> + {:cont, [result.id | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + rescue + _e in UndefinedFunctionError -> + {:halt, + {:error, + "couldn't create feature characteristic with value of unknown type #{value_type}"}} + end + end) + + case characteristic_ids do + {:error, error} -> + {:halt, {:error, error}} + + _ -> + case Provider.create_feature(%{ + name: name, + isEnabled: isEnabled, + characteristics: characteristic_ids + }) do + {:ok, result} -> + {:cont, [result | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + end + end + ) + end + + def relate_instance(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + features = Ash.Changeset.get_argument(changeset, :features) + Provider.relate_instance_features(%Instance{id: result.id}, %{features: features}) + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/info.ex b/lib/diffo/provider/extension/info.ex new file mode 100644 index 0000000..2617ef5 --- /dev/null +++ b/lib/diffo/provider/extension/info.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Info do + use Spark.InfoGenerator, + extension: Diffo.Provider.Extension, + sections: [:provider] + + @doc "Returns true if the module is a BaseInstance-derived resource" + @spec instance?(module()) :: boolean() + def instance?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Instance.Extension in Ash.Resource.Info.extensions(module) + end + + @doc "Returns true if the module is a BaseParty-derived resource" + @spec party?(module()) :: boolean() + def party?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Party.Extension in Ash.Resource.Info.extensions(module) + end + + @doc "Returns true if the module is a BasePlace-derived resource" + @spec place?(module()) :: boolean() + def place?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Place.Extension in Ash.Resource.Info.extensions(module) + end +end diff --git a/lib/diffo/provider/extension/instance_role.ex b/lib/diffo/provider/extension/instance_role.ex new file mode 100644 index 0000000..1247386 --- /dev/null +++ b/lib/diffo/provider/extension/instance_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InstanceRole do + @moduledoc "DSL entity declaring a role a Party or Place kind plays with respect to Instances" + defstruct [:role, :instance_type, :reference, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/party_declaration.ex b/lib/diffo/provider/extension/party_declaration.ex new file mode 100644 index 0000000..589af5b --- /dev/null +++ b/lib/diffo/provider/extension/party_declaration.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PartyDeclaration do + @moduledoc "DSL entity declaring a party role on an Instance" + defstruct [ + :role, + :party_type, + :multiple, + :reference, + :calculate, + :constraints, + __spark_metadata__: nil + ] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/party_role.ex b/lib/diffo/provider/extension/party_role.ex new file mode 100644 index 0000000..5f52b32 --- /dev/null +++ b/lib/diffo/provider/extension/party_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PartyRole do + @moduledoc "DSL entity declaring a role a Party or Place kind plays with respect to Parties" + defstruct [:role, :party_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_characteristics.ex b/lib/diffo/provider/extension/persisters/persist_characteristics.ex new file mode 100644 index 0000000..6b8d352 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_characteristics.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistCharacteristics do + @moduledoc "Persists characteristic declarations and bakes characteristics/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :characteristics]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :characteristics, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def characteristics, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_features.ex b/lib/diffo/provider/extension/persisters/persist_features.ex new file mode 100644 index 0000000..5b02846 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_features.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistFeatures do + @moduledoc "Persists feature declarations and bakes features/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :features]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :features, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def features, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_instances.ex b/lib/diffo/provider/extension/persisters/persist_instances.ex new file mode 100644 index 0000000..313885c --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_instances.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistInstances do + @moduledoc "Persists instance role declarations and bakes instances/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :instances]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :instances, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def instances, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_parties.ex b/lib/diffo/provider/extension/persisters/persist_parties.ex new file mode 100644 index 0000000..0998990 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_parties.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistParties do + @moduledoc "Persists party declarations/roles and bakes parties/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :parties]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :parties, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def parties, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_places.ex b/lib/diffo/provider/extension/persisters/persist_places.ex new file mode 100644 index 0000000..824000f --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_places.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistPlaces do + @moduledoc "Persists place declarations/roles and bakes places/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :places]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :places, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def places, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_specification.ex b/lib/diffo/provider/extension/persisters/persist_specification.ex new file mode 100644 index 0000000..5363e5a --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_specification.ex @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistSpecification do + @moduledoc "Normalises specification DSL options, persists them, and bakes specification/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + spec = [ + id: Transformer.get_option(dsl_state, [:provider, :specification], :id), + name: Transformer.get_option(dsl_state, [:provider, :specification], :name), + type: + Transformer.get_option(dsl_state, [:provider, :specification], :type, :serviceSpecification), + major_version: + Transformer.get_option(dsl_state, [:provider, :specification], :major_version, 1), + minor_version: + Transformer.get_option(dsl_state, [:provider, :specification], :minor_version), + patch_version: + Transformer.get_option(dsl_state, [:provider, :specification], :patch_version), + tmf_version: Transformer.get_option(dsl_state, [:provider, :specification], :tmf_version), + description: Transformer.get_option(dsl_state, [:provider, :specification], :description), + category: Transformer.get_option(dsl_state, [:provider, :specification], :category) + ] + + escaped = Macro.escape(spec) + dsl_state = Transformer.persist(dsl_state, :specification, spec) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def specification, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/place_declaration.ex b/lib/diffo/provider/extension/place_declaration.ex new file mode 100644 index 0000000..db7fad4 --- /dev/null +++ b/lib/diffo/provider/extension/place_declaration.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PlaceDeclaration do + @moduledoc "DSL entity declaring a place role on an Instance" + defstruct [ + :role, + :place_type, + :multiple, + :reference, + :calculate, + :constraints, + __spark_metadata__: nil + ] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/place_role.ex b/lib/diffo/provider/extension/place_role.ex new file mode 100644 index 0000000..a4361e2 --- /dev/null +++ b/lib/diffo/provider/extension/place_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PlaceRole do + @moduledoc "DSL entity declaring a role a Party or Place kind plays with respect to Places" + defstruct [:role, :place_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex new file mode 100644 index 0000000..efb5f47 --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do + @moduledoc "Generates build_before/1 and build_after/2, and injects build arguments into declared create actions" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + alias Diffo.Provider.Extension.ActionCreate + + @build_args [ + specified_by: :uuid, + features: {:array, :uuid}, + characteristics: {:array, :uuid} + ] + + @impl true + def transform(dsl_state) do + spec = Transformer.get_persisted(dsl_state, :specification, []) + + dsl_state = inject_create_arguments(dsl_state) + + {build_before_body, build_after_body} = + if spec[:id] do + before_body = + quote do + changeset + |> Diffo.Provider.Instance.Specification.set_specified_by_argument(specification()) + |> Diffo.Provider.Extension.Feature.set_features_argument(features()) + |> Diffo.Provider.Extension.Characteristic.set_characteristics_argument( + characteristics() + ) + |> Diffo.Provider.Instance.Party.validate_parties(parties()) + end + + after_body = + quote do + Diffo.Provider.Instance.ActionHelper.build_after(changeset, result) + end + + {before_body, after_body} + else + {quote(do: changeset), quote(do: {:ok, result})} + end + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def build_before(changeset), do: unquote(build_before_body) + + @doc false + def build_after(changeset, result), do: unquote(build_after_body) + + @doc false + def characteristic(name), do: Enum.find(characteristics(), &(&1.name == name)) + + @doc false + def feature(name), do: Enum.find(features(), &(&1.name == name)) + + @doc false + def feature_characteristic(feature_name, char_name) do + case feature(feature_name) do + nil -> nil + f -> Enum.find(f.characteristics, &(&1.name == char_name)) + end + end + + @doc false + def party(role), do: Enum.find(parties(), &(&1.role == role)) + + @doc false + def place(role), do: Enum.find(places(), &(&1.role == role)) + end + )} + end + + defp inject_create_arguments(dsl_state) do + action_create_declarations = + Transformer.get_entities(dsl_state, [:provider, :behaviour, :actions]) + |> Enum.filter(&is_struct(&1, ActionCreate)) + + Enum.reduce(action_create_declarations, dsl_state, fn %ActionCreate{name: action_name}, + dsl_state -> + action = + Transformer.get_entities(dsl_state, [:actions]) + |> Enum.find(&(is_struct(&1, Ash.Resource.Actions.Create) and &1.name == action_name)) + + if action do + existing = MapSet.new(action.arguments, & &1.name) + + new_args = + @build_args + |> Enum.reject(fn {name, _} -> MapSet.member?(existing, name) end) + |> Enum.map(fn {name, type} -> + %Ash.Resource.Actions.Argument{ + name: name, + type: type, + public?: false, + allow_nil?: true + } + end) + + updated = %{action | arguments: action.arguments ++ new_args} + + Transformer.replace_entity(dsl_state, [:actions], updated, fn entity -> + is_struct(entity, Ash.Resource.Actions.Create) and entity.name == action_name + end) + else + dsl_state + end + end) + end + + @impl true + def after?(Diffo.Provider.Extension.Persisters.PersistSpecification), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistCharacteristics), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistFeatures), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistParties), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistPlaces), do: true + def after?(_), do: false +end diff --git a/lib/diffo/provider/extension/verifiers/verify_behaviour.ex b/lib/diffo/provider/extension/verifiers/verify_behaviour.ex new file mode 100644 index 0000000..d321c6f --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_behaviour.ex @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyBehaviour do + @moduledoc "Verifies that actions declared in behaviour do exist as Ash actions of the correct type" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.ActionCreate + alias Diffo.Provider.Extension.ActionUpdate + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + behaviour_actions = Verifier.get_entities(dsl_state, [:provider, :behaviour, :actions]) + ash_actions = Verifier.get_entities(dsl_state, [:actions]) + + create_names = + ash_actions + |> Enum.filter(&is_struct(&1, Ash.Resource.Actions.Create)) + |> MapSet.new(& &1.name) + + update_names = + ash_actions + |> Enum.filter(&is_struct(&1, Ash.Resource.Actions.Update)) + |> MapSet.new(& &1.name) + + errors = + Enum.flat_map(behaviour_actions, fn + %ActionCreate{name: name} -> + if MapSet.member?(create_names, name) do + [] + else + [ + DslError.exception( + module: resource, + path: [:provider, :behaviour, :actions], + message: + "behaviour: create #{inspect(name)} does not exist as a create action on this resource" + ) + ] + end + + %ActionUpdate{name: name} -> + if MapSet.member?(update_names, name) do + [] + else + [ + DslError.exception( + module: resource, + path: [:provider, :behaviour, :actions], + message: + "behaviour: update #{inspect(name)} does not exist as an update action on this resource" + ) + ] + end + end) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_characteristics.ex b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex new file mode 100644 index 0000000..bf05037 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyCharacteristics do + @moduledoc "Verifies characteristic names are unique and value_type modules exist" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + characteristics = Verifier.get_entities(dsl_state, [:provider, :characteristics]) + + duplicate_errors = + characteristics + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, chars} -> length(chars) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :characteristics], + message: "characteristics: name #{inspect(name)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(characteristics, [], fn char, acc -> + case module_from_value_type(char.value_type) do + {:ok, module} -> + if Code.ensure_loaded?(module) do + acc + else + [ + DslError.exception( + module: resource, + path: [:provider, :characteristics, char.name], + message: "characteristics: value_type #{inspect(module)} does not exist" + ) + | acc + ] + end + + :error -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp module_from_value_type({:array, module}) when is_atom(module), do: {:ok, module} + defp module_from_value_type(module) when is_atom(module), do: {:ok, module} + defp module_from_value_type(_), do: :error +end diff --git a/lib/diffo/provider/extension/verifiers/verify_features.ex b/lib/diffo/provider/extension/verifiers/verify_features.ex new file mode 100644 index 0000000..882dcdc --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_features.ex @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyFeatures do + @moduledoc "Verifies feature names are unique and feature characteristic value_type modules exist" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + features = Verifier.get_entities(dsl_state, [:provider, :features]) + + duplicate_errors = + features + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, fs} -> length(fs) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :features], + message: "features: name #{inspect(name)} is declared more than once" + ) + end) + + char_errors = + Enum.reduce(features, [], fn feature, acc -> + duplicate_char_errors = + feature.characteristics + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, chars} -> length(chars) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics], + message: + "features: characteristic name #{inspect(name)} is declared more than once in #{inspect(feature.name)}" + ) + end) + + type_errors = + Enum.reduce(feature.characteristics || [], [], fn char, inner_acc -> + case module_from_value_type(char.value_type) do + {:ok, module} -> + if Code.ensure_loaded?(module) do + inner_acc + else + [ + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics, char.name], + message: + "features: characteristic value_type #{inspect(module)} does not exist" + ) + | inner_acc + ] + end + + :error -> + inner_acc + end + end) + + acc ++ duplicate_char_errors ++ type_errors + end) + + case duplicate_errors ++ char_errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp module_from_value_type({:array, module}) when is_atom(module), do: {:ok, module} + defp module_from_value_type(module) when is_atom(module), do: {:ok, module} + defp module_from_value_type(_), do: :error +end diff --git a/lib/diffo/provider/extension/verifiers/verify_instances.ex b/lib/diffo/provider/extension/verifiers/verify_instances.ex new file mode 100644 index 0000000..c259e17 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_instances.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyInstances do + @moduledoc "Verifies instance role declarations — no duplicates, instance_type modules must exist and extend BaseInstance" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + instances = Verifier.get_entities(dsl_state, [:provider, :instances]) + + duplicate_errors = + instances + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, roles} -> length(roles) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :instances], + message: "instances: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(instances, [], fn role, acc -> + mod = role.instance_type + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :instances, role.role], + message: "instances: instance_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.instance?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :instances, role.role], + message: "instances: instance_type #{inspect(mod)} does not extend BaseInstance" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_parties.ex b/lib/diffo/provider/extension/verifiers/verify_parties.ex new file mode 100644 index 0000000..8a67bed --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_parties.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyParties do + @moduledoc "Verifies party declarations and roles — no duplicates, party_type modules must exist and extend BaseParty" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + parties = Verifier.get_entities(dsl_state, [:provider, :parties]) + + duplicate_errors = + parties + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, declarations} -> length(declarations) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :parties], + message: "parties: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(parties, [], fn party, acc -> + mod = Map.get(party, :party_type) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :parties, party.role], + message: "parties: party_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.party?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :parties, party.role], + message: "parties: party_type #{inspect(mod)} does not extend BaseParty" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_places.ex b/lib/diffo/provider/extension/verifiers/verify_places.ex new file mode 100644 index 0000000..c15f9e0 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_places.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyPlaces do + @moduledoc "Verifies place declarations and roles — no duplicates, place_type modules must exist and extend BasePlace" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + places = Verifier.get_entities(dsl_state, [:provider, :places]) + + duplicate_errors = + places + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, declarations} -> length(declarations) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :places], + message: "places: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(places, [], fn place, acc -> + mod = Map.get(place, :place_type) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :places, place.role], + message: "places: place_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.place?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :places, place.role], + message: "places: place_type #{inspect(mod)} does not extend BasePlace" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_specification.ex b/lib/diffo/provider/extension/verifiers/verify_specification.ex new file mode 100644 index 0000000..f0f0c45 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_specification.ex @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifySpecification do + @moduledoc "Verifies specification DSL values satisfy the Specification resource constraints" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @spec_fields [ + :name, + :type, + :major_version, + :minor_version, + :patch_version, + :tmf_version, + :description, + :category + ] + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + errors = check_id(dsl_state, resource) ++ check_attributes(dsl_state, resource) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp check_id(dsl_state, resource) do + spec_id = Verifier.get_option(dsl_state, [:provider, :specification], :id) + + if spec_id && !Diffo.Uuid.uuid4?(spec_id) do + [ + DslError.exception( + module: resource, + path: [:provider, :specification, :id], + message: "specification: id must be a valid UUID4" + ) + ] + else + [] + end + end + + defp check_attributes(dsl_state, resource) do + spec_attrs = + Ash.Resource.Info.attributes(Diffo.Provider.Specification) + |> Map.new(&{&1.name, &1}) + + Enum.flat_map(@spec_fields, fn field -> + value = Verifier.get_option(dsl_state, [:provider, :specification], field) + attr = Map.get(spec_attrs, field) + + if not is_nil(value) && not is_nil(attr) do + case Ash.Type.apply_constraints(attr.type, value, attr.constraints) do + {:ok, _} -> + [] + + {:error, errors} -> + [ + DslError.exception( + module: resource, + path: [:provider, :specification, field], + message: "specification: #{field} - #{format_errors(errors)}" + ) + ] + end + else + [] + end + end) + end + + defp format_errors(errors) when is_list(errors) do + if Keyword.keyword?(errors) do + format_error(errors) + else + errors |> Enum.map(&format_error/1) |> Enum.join(", ") + end + end + + defp format_error(kwlist) do + {message, bindings} = Keyword.pop(kwlist, :message, "invalid value") + + Enum.reduce(bindings, message, fn {key, val}, msg -> + String.replace(msg, "%{#{key}}", to_string(val)) + end) + end +end diff --git a/test/instance_extension/assigner_test.exs b/test/provider/extension/assigner_test.exs similarity index 99% rename from test/instance_extension/assigner_test.exs rename to test/provider/extension/assigner_test.exs index 9c5ca9e..c6e1632 100644 --- a/test/instance_extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.AssignerTest do +defmodule Diffo.Provider.Extension.AssignerTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Provider.Specification diff --git a/test/instance_extension/characteristic_test.exs b/test/provider/extension/characteristic_test.exs similarity index 92% rename from test/instance_extension/characteristic_test.exs rename to test/provider/extension/characteristic_test.exs index bdb6234..c961704 100644 --- a/test/instance_extension/characteristic_test.exs +++ b/test/provider/extension/characteristic_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.CharacteristicTest do +defmodule Diffo.Provider.Extension.CharacteristicTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Parties diff --git a/test/instance_extension/feature_test.exs b/test/provider/extension/feature_test.exs similarity index 94% rename from test/instance_extension/feature_test.exs rename to test/provider/extension/feature_test.exs index f572d34..1122379 100644 --- a/test/instance_extension/feature_test.exs +++ b/test/provider/extension/feature_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.FeatureTest do +defmodule Diffo.Provider.Extension.FeatureTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Parties diff --git a/test/instance_extension/transformer_test.exs b/test/provider/extension/instance_transformer_test.exs similarity index 97% rename from test/instance_extension/transformer_test.exs rename to test/provider/extension/instance_transformer_test.exs index 1e1ed66..c5b0395 100644 --- a/test/instance_extension/transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -2,16 +2,16 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.TransformerTest do +defmodule Diffo.Provider.Extension.InstanceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true alias Diffo.Test.Shelf alias Diffo.Test.Card - alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.Feature + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Feature + alias Diffo.Provider.Extension.PlaceDeclaration alias Diffo.Provider.Instance.Info - alias Diffo.Provider.Instance.Extension.PlaceDeclaration describe "PersistSpecification" do test "bakes specification/0 onto the resource" do diff --git a/test/instance_extension/verifier_test.exs b/test/provider/extension/instance_verifier_test.exs similarity index 95% rename from test/instance_extension/verifier_test.exs rename to test/provider/extension/instance_verifier_test.exs index 0273c97..dc2dba4 100644 --- a/test/instance_extension/verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.VerifierTest do +defmodule Diffo.Provider.Extension.InstanceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,7 +21,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with invalid spec id" end - structure do + provider do specification do id "ef016d85-9dbd-429c-04da-1df56cc7dda5" name "invalid" @@ -45,7 +45,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-camelCase specification name" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "not camel case" @@ -69,7 +69,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with invalid specification type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -94,7 +94,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with negative major_version" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -119,7 +119,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with tmf_version below minimum" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -146,7 +146,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate characteristic name" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -175,7 +175,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -203,7 +203,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent array characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -233,7 +233,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate feature names" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -265,7 +265,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate feature characteristic names" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -296,7 +296,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent feature characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -329,7 +329,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate party roles" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -358,7 +358,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent party_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -386,7 +386,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with party_type that is not a BaseParty" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -416,16 +416,16 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with behaviour referencing a missing create action" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end - end - behaviour do - actions do - create :nonexistent + behaviour do + actions do + create :nonexistent + end end end end @@ -446,16 +446,16 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with behaviour referencing a missing update action" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end - end - behaviour do - actions do - update :nonexistent + behaviour do + actions do + update :nonexistent + end end end end diff --git a/test/instance_extension/party_test.exs b/test/provider/extension/party_test.exs similarity index 98% rename from test/instance_extension/party_test.exs rename to test/provider/extension/party_test.exs index 56f717a..530089f 100644 --- a/test/instance_extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PartyTest do +defmodule Diffo.Provider.Extension.PartyTest do @moduledoc false use ExUnit.Case, async: true @@ -26,7 +26,7 @@ defmodule Diffo.InstanceExtension.PartyTest do roles = PartyInfo.instances(Organization) assert length(roles) == 1 assert hd(roles).role == :facilitator - assert hd(roles).party_type == Diffo.Provider.Instance + assert hd(roles).instance_type == Diffo.Provider.Instance end test "party roles are declared" do diff --git a/test/party_extension/transformer_test.exs b/test/provider/extension/party_transformer_test.exs similarity index 92% rename from test/party_extension/transformer_test.exs rename to test/provider/extension/party_transformer_test.exs index 3614d8b..ead64ec 100644 --- a/test/party_extension/transformer_test.exs +++ b/test/provider/extension/party_transformer_test.exs @@ -2,15 +2,15 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PartyExtension.TransformerTest do +defmodule Diffo.Provider.Extension.PartyTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true alias Diffo.Test.Organization alias Diffo.Test.Person - alias Diffo.Provider.Party.Extension.InstanceRole - alias Diffo.Provider.Party.Extension.PartyRole - alias Diffo.Provider.Party.Extension.PlaceRole + alias Diffo.Provider.Extension.InstanceRole + alias Diffo.Provider.Extension.PartyRole + alias Diffo.Provider.Extension.PlaceRole alias Diffo.Provider.Party.Extension.Info describe "PersistInstances" do diff --git a/test/party_extension/verifier_test.exs b/test/provider/extension/party_verifier_test.exs similarity index 79% rename from test/party_extension/verifier_test.exs rename to test/provider/extension/party_verifier_test.exs index d4d64ea..505dc32 100644 --- a/test/party_extension/verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PartyExtension.VerifierTest do +defmodule Diffo.Provider.Extension.PartyVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,9 +21,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate instance role" end - instances do - role :operator, Diffo.Provider.Instance - role :operator, Diffo.Provider.Instance + provider do + instances do + role :operator, Diffo.Provider.Instance + role :operator, Diffo.Provider.Instance + end end end end @@ -43,8 +45,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent instance type" end - instances do - role :operator, NonExistent.InstanceModule + provider do + instances do + role :operator, NonExistent.InstanceModule + end end end end @@ -64,8 +68,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with party as instance type" end - instances do - role :operator, Diffo.Test.Organization + provider do + instances do + role :operator, Diffo.Test.Organization + end end end end @@ -87,9 +93,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate party role" end - parties do - role :employer, Diffo.Test.Organization - role :employer, Diffo.Test.Organization + provider do + parties do + role :employer, Diffo.Test.Organization + role :employer, Diffo.Test.Organization + end end end end @@ -109,8 +117,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent party type" end - parties do - role :employer, NonExistent.PartyModule + provider do + parties do + role :employer, NonExistent.PartyModule + end end end end @@ -130,8 +140,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with instance as party type" end - parties do - role :employer, Diffo.Provider.Instance + provider do + parties do + role :employer, Diffo.Provider.Instance + end end end end @@ -153,9 +165,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate place role" end - places do - role :headquarters, Diffo.Provider.Place - role :headquarters, Diffo.Provider.Place + provider do + places do + role :headquarters, Diffo.Provider.Place + role :headquarters, Diffo.Provider.Place + end end end end @@ -175,8 +189,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent place type" end - places do - role :headquarters, NonExistent.PlaceModule + provider do + places do + role :headquarters, NonExistent.PlaceModule + end end end end @@ -196,8 +212,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with party as place type" end - places do - role :headquarters, Diffo.Test.Organization + provider do + places do + role :headquarters, Diffo.Test.Organization + end end end end diff --git a/test/instance_extension/place_test.exs b/test/provider/extension/place_test.exs similarity index 98% rename from test/instance_extension/place_test.exs rename to test/provider/extension/place_test.exs index 87e5cf5..990733d 100644 --- a/test/instance_extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PlaceTest do +defmodule Diffo.Provider.Extension.PlaceTest do @moduledoc false use ExUnit.Case, async: true diff --git a/test/place_extension/transformer_test.exs b/test/provider/extension/place_transformer_test.exs similarity index 89% rename from test/place_extension/transformer_test.exs rename to test/provider/extension/place_transformer_test.exs index 98c5158..80871e1 100644 --- a/test/place_extension/transformer_test.exs +++ b/test/provider/extension/place_transformer_test.exs @@ -2,14 +2,14 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PlaceExtension.TransformerTest do +defmodule Diffo.Provider.Extension.PlaceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true alias Diffo.Test.GeographicSite - alias Diffo.Provider.Place.Extension.InstanceRole - alias Diffo.Provider.Place.Extension.PartyRole - alias Diffo.Provider.Place.Extension.PlaceRole + alias Diffo.Provider.Extension.InstanceRole + alias Diffo.Provider.Extension.PartyRole + alias Diffo.Provider.Extension.PlaceRole alias Diffo.Provider.Place.Extension.Info describe "PersistInstances" do diff --git a/test/place_extension/verifier_test.exs b/test/provider/extension/place_verifier_test.exs similarity index 79% rename from test/place_extension/verifier_test.exs rename to test/provider/extension/place_verifier_test.exs index 48c4eb1..232e21a 100644 --- a/test/place_extension/verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PlaceExtension.VerifierTest do +defmodule Diffo.Provider.Extension.PlaceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,9 +21,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate instance role" end - instances do - role :site_for, Diffo.Provider.Instance - role :site_for, Diffo.Provider.Instance + provider do + instances do + role :site_for, Diffo.Provider.Instance + role :site_for, Diffo.Provider.Instance + end end end end @@ -43,8 +45,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent instance type" end - instances do - role :site_for, NonExistent.InstanceModule + provider do + instances do + role :site_for, NonExistent.InstanceModule + end end end end @@ -64,8 +68,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with party as instance type" end - instances do - role :site_for, Diffo.Test.Organization + provider do + instances do + role :site_for, Diffo.Test.Organization + end end end end @@ -87,9 +93,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate party role" end - parties do - role :managed_by, Diffo.Test.Organization - role :managed_by, Diffo.Test.Organization + provider do + parties do + role :managed_by, Diffo.Test.Organization + role :managed_by, Diffo.Test.Organization + end end end end @@ -109,8 +117,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent party type" end - parties do - role :managed_by, NonExistent.PartyModule + provider do + parties do + role :managed_by, NonExistent.PartyModule + end end end end @@ -130,8 +140,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with instance as party type" end - parties do - role :managed_by, Diffo.Provider.Instance + provider do + parties do + role :managed_by, Diffo.Provider.Instance + end end end end @@ -153,9 +165,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate place role" end - places do - role :contained_in, Diffo.Provider.Place - role :contained_in, Diffo.Provider.Place + provider do + places do + role :contained_in, Diffo.Provider.Place + role :contained_in, Diffo.Provider.Place + end end end end @@ -175,8 +189,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent place type" end - places do - role :contained_in, NonExistent.PlaceModule + provider do + places do + role :contained_in, NonExistent.PlaceModule + end end end end @@ -196,8 +212,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with party as place type" end - places do - role :contained_in, Diffo.Test.Organization + provider do + places do + role :contained_in, Diffo.Test.Organization + end end end end diff --git a/test/instance_extension/specification_test.exs b/test/provider/extension/specification_test.exs similarity index 96% rename from test/instance_extension/specification_test.exs rename to test/provider/extension/specification_test.exs index 87349c4..952da58 100644 --- a/test/instance_extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.SpecificationTest do +defmodule Diffo.Provider.Extension.SpecificationTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Servo diff --git a/test/support/resource/broadband.ex b/test/support/resource/broadband.ex index c09df65..7eac2d6 100644 --- a/test/support/resource/broadband.ex +++ b/test/support/resource/broadband.ex @@ -22,7 +22,7 @@ defmodule Diffo.Test.Broadband do plural_name :broadbands end - structure do + provider do specification do id "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5" name "broadband" @@ -31,11 +31,11 @@ defmodule Diffo.Test.Broadband do description "A broadband access service" category "Access" end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/broadband_v2.ex b/test/support/resource/broadband_v2.ex index 4abf3c4..3dc4b1b 100644 --- a/test/support/resource/broadband_v2.ex +++ b/test/support/resource/broadband_v2.ex @@ -22,7 +22,7 @@ defmodule Diffo.Test.BroadbandV2 do plural_name :broadband_v2s end - structure do + provider do specification do id "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c" name "broadband" @@ -31,11 +31,11 @@ defmodule Diffo.Test.BroadbandV2 do description "A broadband access service — :fttb technology retired" category "Access" end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/card.ex b/test/support/resource/card.ex index 9792b8e..6ec064b 100644 --- a/test/support/resource/card.ex +++ b/test/support/resource/card.ex @@ -10,7 +10,7 @@ defmodule Diffo.Test.Card do """ alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -26,7 +26,7 @@ defmodule Diffo.Test.Card do plural_name :Cards end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "card" @@ -39,11 +39,11 @@ defmodule Diffo.Test.Card do characteristic :card, CardValue characteristic :ports, AssignableValue end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/carrier.ex b/test/support/resource/carrier.ex index c99abf6..3614c5c 100644 --- a/test/support/resource/carrier.ex +++ b/test/support/resource/carrier.ex @@ -52,11 +52,13 @@ defmodule Diffo.Test.Carrier do end end - instances do - role :provider, Diffo.Provider.Instance - end + provider do + instances do + role :provider, Diffo.Provider.Instance + end - places do - role :exchange, Diffo.Provider.Place + places do + role :exchange, Diffo.Provider.Place + end end end diff --git a/test/support/resource/exchange_building.ex b/test/support/resource/exchange_building.ex index d12d96c..83e9f4b 100644 --- a/test/support/resource/exchange_building.ex +++ b/test/support/resource/exchange_building.ex @@ -54,11 +54,13 @@ defmodule Diffo.Test.ExchangeBuilding do end end - parties do - role :operator, Diffo.Test.Carrier - end + provider do + instances do + role :host, Diffo.Provider.Instance + end - instances do - role :host, Diffo.Provider.Instance + parties do + role :operator, Diffo.Test.Carrier + end end end diff --git a/test/support/resource/geographic_site.ex b/test/support/resource/geographic_site.ex index 81ce42c..614964e 100644 --- a/test/support/resource/geographic_site.ex +++ b/test/support/resource/geographic_site.ex @@ -38,15 +38,17 @@ defmodule Diffo.Test.GeographicSite do end end - instances do - role :installed_at, Diffo.Provider.Instance - end + provider do + instances do + role :installed_at, Diffo.Provider.Instance + end - parties do - role :managed_by, Diffo.Test.Organization - end + parties do + role :managed_by, Diffo.Test.Organization + end - places do - role :contained_in, Diffo.Provider.Place + places do + role :contained_in, Diffo.Provider.Place + end end end diff --git a/test/support/resource/organization.ex b/test/support/resource/organization.ex index 0a7c7bc..bbe81c4 100644 --- a/test/support/resource/organization.ex +++ b/test/support/resource/organization.ex @@ -37,15 +37,17 @@ defmodule Diffo.Test.Organization do end end - instances do - role :facilitator, Diffo.Provider.Instance - end + provider do + instances do + role :facilitator, Diffo.Provider.Instance + end - parties do - role :employer, Diffo.Test.Person - end + parties do + role :employer, Diffo.Test.Person + end - places do - role :headquarters, Diffo.Provider.Place + places do + role :headquarters, Diffo.Provider.Place + end end end diff --git a/test/support/resource/person.ex b/test/support/resource/person.ex index e260004..1e559aa 100644 --- a/test/support/resource/person.ex +++ b/test/support/resource/person.ex @@ -37,15 +37,17 @@ defmodule Diffo.Test.Person do end end - instances do - role :overseer, Diffo.Provider.Instance - end + provider do + instances do + role :overseer, Diffo.Provider.Instance + end - parties do - role :manager, Diffo.Test.Person - end + parties do + role :manager, Diffo.Test.Person + end - places do - role :residence, Diffo.Provider.Place + places do + role :residence, Diffo.Provider.Place + end end end diff --git a/test/support/resource/shelf.ex b/test/support/resource/shelf.ex index b2f87b9..4b24237 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/shelf.ex @@ -11,7 +11,7 @@ defmodule Diffo.Test.Shelf do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -29,7 +29,7 @@ defmodule Diffo.Test.Shelf do plural_name :Shelves end - structure do + provider do specification do id "ef016d85-9dbd-429c-84da-1df56cc7dda5" name "shelf" @@ -59,20 +59,20 @@ defmodule Diffo.Test.Shelf do parties do party :facilitator, Diffo.Test.Organization party :overseer, Diffo.Test.Person - party :provider, Diffo.Test.Organization, reference: true + party_ref :provider, Diffo.Test.Organization party :manager, Diffo.Test.Organization, calculate: :manager_calc parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] end places do place :installation_site, Diffo.Provider.Place - place :billing_address, Diffo.Provider.Place, reference: true + place_ref :billing_address, Diffo.Provider.Place end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end From c54eae3a95d25b94dab71b59c02a709ea34913e9 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 16:30:54 +0930 Subject: [PATCH 2/3] documents and guidance --- AGENTS.md | 152 ++++ .../dsls/DSL-Diffo.Provider.Extension.md | 762 ++++++++++++++++++ .../DSL-Diffo.Provider.Instance.Extension.md | 530 ------------ .../DSL-Diffo.Provider.Party.Extension.md | 163 ---- .../use_diffo_provider_extension.livemd | 133 +-- mix.exs | 16 +- usage-rules.md | 283 +++++-- 7 files changed, 1212 insertions(+), 827 deletions(-) create mode 100644 AGENTS.md create mode 100644 documentation/dsls/DSL-Diffo.Provider.Extension.md delete mode 100644 documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md delete mode 100644 documentation/dsls/DSL-Diffo.Provider.Party.Extension.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..130e77b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ + + +# AGENTS.md — Diffo + +AI agent guidance for the Diffo source repository. + +## What this project is + +Diffo is a Telecommunications Management Forum (TMF) Service and Resource Manager, built +on [Ash Framework](https://www.ash-hq.org/) + [AshNeo4j](https://github.com/diffo-dev/ash_neo4j) + [Neo4j](https://github.com/neo4j/neo4j). It models TMF 638/639 Service and Resource inventory and provides a Spark DSL for defining domain-specific instance, party, and place kinds. + +## Before making changes + +1. Read `usage-rules.md` — Diffo-specific DSL rules. +2. Read `CLAUDE.md` — dependency usage rules (Ash, Elixir, OTP, AshNeo4j, Spark). +3. Consult the skill at `.claude/skills/diffo-framework/` for Ash ecosystem patterns. + +## Project structure + +``` +lib/diffo/provider/ + extension.ex # Unified Spark DSL extension (provider do) + extension/ + info.ex # Runtime introspection via Spark.InfoGenerator + characteristic.ex # Characteristic build helpers + feature.ex # Feature build helpers + instance_role.ex # InstanceRole struct + party_declaration.ex # PartyDeclaration struct + place_declaration.ex # PlaceDeclaration struct + party_role.ex # PartyRole struct (Party/Place kinds) + place_role.ex # PlaceRole struct (Party/Place kinds) + persisters/ # Spark transformers — bake DSL state into module + transformers/ # TransformBehaviour — action argument injection + verifiers/ # Compile-time DSL correctness checks + base_instance.ex # Ash Fragment for Instance resources + base_party.ex # Ash Fragment for Party resources + base_place.ex # Ash Fragment for Place resources + components/ + instance/extension.ex # Thin marker (sections: []) — kind identification + party/extension.ex # Thin marker + place/extension.ex # Thin marker + +test/provider/extension/ # All provider extension tests + instance_transformer_test.exs + party_transformer_test.exs + place_transformer_test.exs + instance_verifier_test.exs + party_verifier_test.exs + place_verifier_test.exs + party_test.exs # Integration: parties enforcement + place_test.exs # Integration: places enforcement + specification_test.exs # Integration: spec roundtrip + characteristic_test.exs # Integration: characteristic creation + feature_test.exs # Integration: feature creation + assigner_test.exs # Integration: resource assignment + +test/support/ + resources/ # Test domain resources (Shelf, Card, Organization, etc.) + domains/ # Test domains (Servo, Nbn) +``` + +## The unified `provider do` DSL + +All DSL declarations use a single `provider do` section — there is no `structure do`, +top-level `behaviour do`, or bare `instances/parties/places do`. + +### Instance resources (`BaseInstance`) + +```elixir +provider do + specification do + id "da9b207a-..." # stable UUID4 — never change after first commit + name "myService" # camelCase + type :serviceSpecification + major_version 1 + description "..." + category "..." + end + + characteristics do + characteristic :slot_value, MyApp.SlotValue + characteristic :ports, {:array, MyApp.Port} + end + + features do + feature :advanced_routing, is_enabled?: false do + characteristic :policy, MyApp.RoutingPolicy + end + end + + parties do + party :provider, MyApp.RSP # singular, direct edge + parties :engineers, MyApp.Engineer, constraints: [min: 1, max: 5] + party_ref :owner, MyApp.Organization # no direct edge + party :operator, MyApp.RSP, calculate: :derive_operator + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build # injects :specified_by, :features, :characteristics + end + end +end +``` + +### Party and Place resources (`BaseParty` / `BasePlace`) + +```elixir +provider do + instances do + role :provider, MyApp.BroadbandService + instance_ref :manages, MyApp.InternalService # no direct edge + end + parties do + role :employer, MyApp.Organization + end + places do + role :headquarters, MyApp.GeographicSite + end +end +``` + +## Running tests + +Integration tests require a running Neo4j instance. + +```sh +mix test # full suite +mix test test/provider/extension/ # extension tests only +mix test path/to/test.exs:LINE # single test +mix test --max-failures 5 # stop early +``` + +## Common agent mistakes + +- Using old `structure do` / top-level `instances do` — use `provider do` only. +- Using `party :role, Type, reference: true` — use `party_ref :role, Type`. +- Calling `build_before/1` or `build_after/2` in actions — these run automatically. +- Declaring `:specified_by`, `:features`, `:characteristics` as action arguments. +- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; + run `mix spark.cheat_sheets` to regenerate it. +- Editing content between `` markers in `CLAUDE.md` — that is + auto-generated by `mix usage_rules.sync`. diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md new file mode 100644 index 0000000..ba5e90f --- /dev/null +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -0,0 +1,762 @@ + +# Diffo.Provider.Extension + +Unified DSL extension for all Diffo provider resource kinds. + +Provides a single `provider do` section for Instance, Party, and Place kinds. +The sections within `provider do` are self-similar across kinds — each kind uses +the sections relevant to it, and verifiers enforce correct usage. + +## Instance + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + end + + characteristics do + characteristic :circuit, Diffo.Access.Circuit + end + + features do + feature :dynamic_line_management do + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build + end + end + end + +## Party + + provider do + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + parties do + role :employer, MyApp.Person + end + places do + role :headquarters, MyApp.GeographicSite + end + end + +## Place + + provider do + instances do + role :site_for, MyApp.AccessService + end + parties do + role :managed_by, MyApp.Organization + end + places do + role :within, MyApp.GeographicSite + end + end + +See `Diffo.Provider.Extension.Info` for runtime introspection. +See `Diffo.Provider.BaseInstance`, `Diffo.Provider.BaseParty`, `Diffo.Provider.BasePlace` +for full usage documentation. + + +## provider +Provider DSL — structure, roles, and behaviour for this resource kind + +### Nested DSLs + * [specification](#provider-specification) + * [characteristics](#provider-characteristics) + * characteristic + * [features](#provider-features) + * feature + * characteristic + * [parties](#provider-parties) + * party + * parties + * party_ref + * role + * [places](#provider-places) + * place + * places + * place_ref + * role + * [instances](#provider-instances) + * role + * instance_ref + * [behaviour](#provider-behaviour) + * actions + * create + * update + + + + +### provider.specification +Defines the Instance Specification + + + +### Examples +``` +specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + major_version 1 + description "An access network service" + category "Network Service" +end + +``` + + + + +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`id`](#provider-specification-id){: #provider-specification-id .spark-required} | `String.t` | | The id of the specification, a uuid4 the same in all environments, unique for name and major_version. | +| [`name`](#provider-specification-name){: #provider-specification-name .spark-required} | `String.t` | | The name of the specification. | +| [`type`](#provider-specification-type){: #provider-specification-type } | `atom` | `:serviceSpecification` | The type of the specification. | +| [`major_version`](#provider-specification-major_version){: #provider-specification-major_version } | `integer` | `1` | The major version of the specification. | +| [`minor_version`](#provider-specification-minor_version){: #provider-specification-minor_version } | `integer` | | The minor version of the specification. | +| [`patch_version`](#provider-specification-patch_version){: #provider-specification-patch_version } | `integer` | | The patch version of the specification. | +| [`tmf_version`](#provider-specification-tmf_version){: #provider-specification-tmf_version } | `integer` | | The TMF API version of the specification, e.g. 4. | +| [`description`](#provider-specification-description){: #provider-specification-description } | `String.t` | | A generic description of the specified service or resource. | +| [`category`](#provider-specification-category){: #provider-specification-category } | `String.t` | | The category the specified service or resource belongs to. | + + + + +### provider.characteristics +List of Instance Characteristics + +### Nested DSLs + * [characteristic](#provider-characteristics-characteristic) + + +### Examples +``` +characteristics do + characteristic :circuit, Diffo.Access.Circuit + characteristic :line, Diffo.Access.Line +end + +``` + + + + +### provider.characteristics.characteristic +```elixir +characteristic name, value_type +``` + + +Adds a Characteristic + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-characteristics-characteristic-name){: #provider-characteristics-characteristic-name .spark-required} | `atom` | | The name of the characteristic. | +| [`value_type`](#provider-characteristics-characteristic-value_type){: #provider-characteristics-characteristic-value_type } | `any` | | The type of the characteristic value — a module or `{:array, module}` for an array. | + + + + + + + + +### provider.features +Configuration for Instance Features + +### Nested DSLs + * [feature](#provider-features-feature) + * characteristic + + +### Examples +``` +features do + feature :dynamic_line_management do + is_enabled? true + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end +end + +``` + + + + +### provider.features.feature +```elixir +feature name +``` + + +Adds a Feature + +### Nested DSLs + * [characteristic](#provider-features-feature-characteristic) + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-features-feature-name){: #provider-features-feature-name .spark-required} | `atom` | | The name of the feature. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`is_enabled?`](#provider-features-feature-is_enabled?){: #provider-features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true. | + + +### provider.features.feature.characteristic +```elixir +characteristic name, value_type +``` + + +Adds a Characteristic + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-features-feature-characteristic-name){: #provider-features-feature-characteristic-name .spark-required} | `atom` | | The name of the characteristic. | +| [`value_type`](#provider-features-feature-characteristic-value_type){: #provider-features-feature-characteristic-value_type } | `any` | | The type of the characteristic value — a module or `{:array, module}` for an array. | + + + + + + + + + + + + +### provider.parties +Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds + +### Nested DSLs + * [party](#provider-parties-party) + * [parties](#provider-parties-parties) + * [party_ref](#provider-parties-party_ref) + * [role](#provider-parties-role) + + +### Examples +``` +# Instance +parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] +end + +# Party or Place +parties do + role :employer, MyApp.Person +end + +``` + + + + +### provider.parties.party +```elixir +party role, party_type +``` + + +Declares a singular party role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-party-role){: #provider-parties-party-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-party-party_type){: #provider-parties-party-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-party-calculate){: #provider-parties-party-calculate } | `atom` | | Ash calculation on this resource that produces the party. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.parties +```elixir +parties role, party_type +``` + + +Declares a plural party role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-parties-role){: #provider-parties-parties-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-parties-party_type){: #provider-parties-parties-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-parties-calculate){: #provider-parties-parties-calculate } | `atom` | | Ash calculation on this resource that produces the party. | +| [`constraints`](#provider-parties-parties-constraints){: #provider-parties-parties-constraints } | `keyword` | | Multiplicity constraints, e.g. [min: 1, max: 3]. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.party_ref +```elixir +party_ref role, party_type +``` + + +Declares a singular reference party role — no direct PartyRef edge, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-party_ref-role){: #provider-parties-party_ref-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-party_ref-party_type){: #provider-parties-party_ref-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-party_ref-calculate){: #provider-parties-party_ref-calculate } | `atom` | | Ash calculation on this resource that produces the party. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.role +```elixir +role role, party_type +``` + + +Declares a role this Party or Place kind plays with respect to other Parties + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-role-role){: #provider-parties-role-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-role-party_type){: #provider-parties-role-party_type } | `any` | | The module of the related Party kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyRole` + + +### provider.places +Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds + +### Nested DSLs + * [place](#provider-places-place) + * [places](#provider-places-places) + * [place_ref](#provider-places-place_ref) + * [role](#provider-places-role) + + +### Examples +``` +# Instance +places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress +end + +# Party or Place +places do + role :headquarters, MyApp.GeographicSite +end + +``` + + + + +### provider.places.place +```elixir +place role, place_type +``` + + +Declares a singular place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-place-role){: #provider-places-place-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-place-place_type){: #provider-places-place-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-place-calculate){: #provider-places-place-calculate } | `atom` | | Ash calculation on this resource that produces the place. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.places +```elixir +places role, place_type +``` + + +Declares a plural place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-places-role){: #provider-places-places-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-places-place_type){: #provider-places-places-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-places-calculate){: #provider-places-places-calculate } | `atom` | | Ash calculation on this resource that produces the place. | +| [`constraints`](#provider-places-places-constraints){: #provider-places-places-constraints } | `keyword` | | Multiplicity constraints, e.g. [min: 1, max: 3]. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.place_ref +```elixir +place_ref role, place_type +``` + + +Declares a singular reference place role — no direct PlaceRef edge, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-place_ref-role){: #provider-places-place_ref-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-place_ref-place_type){: #provider-places-place_ref-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-place_ref-calculate){: #provider-places-place_ref-calculate } | `atom` | | Ash calculation on this resource that produces the place. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.role +```elixir +role role, place_type +``` + + +Declares a role this Party or Place kind plays with respect to Places + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-role-role){: #provider-places-role-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-role-place_type){: #provider-places-role-place_type } | `any` | | The module of the related Place kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceRole` + + +### provider.instances +Declares the roles this Party or Place kind plays with respect to Instances + +### Nested DSLs + * [role](#provider-instances-role) + * [instance_ref](#provider-instances-instance_ref) + + +### Examples +``` +instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService +end + +``` + + + + +### provider.instances.role +```elixir +role role, instance_type +``` + + +Declares a role this Party or Place kind plays with respect to Instances + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-instances-role-role){: #provider-instances-role-role .spark-required} | `atom` | | The role name. | +| [`instance_type`](#provider-instances-role-instance_type){: #provider-instances-role-instance_type } | `any` | | The module of the related Instance kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InstanceRole` + +### provider.instances.instance_ref +```elixir +instance_ref role, instance_type +``` + + +Declares a reference instance role — no direct edge created, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-instances-instance_ref-role){: #provider-instances-instance_ref-role .spark-required} | `atom` | | The role name. | +| [`instance_type`](#provider-instances-instance_ref-instance_type){: #provider-instances-instance_ref-instance_type } | `any` | | The module of the related Instance kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InstanceRole` + + +### provider.behaviour +Defines the behavioural wiring for the Instance — actions, and in future triggers + +### Nested DSLs + * [actions](#provider-behaviour-actions) + * create + * update + + +### Examples +``` +behaviour do + actions do + create :build + end +end + +``` + + + +### provider.behaviour.actions +Declares which actions to wire for instance behaviour + +### Nested DSLs + * [create](#provider-behaviour-actions-create) + * [update](#provider-behaviour-actions-update) + + +### Examples +``` +actions do + create :build + update :define +end + +``` + + + + +### provider.behaviour.actions.create +```elixir +create name +``` + + +Marks a create action for instance build wiring + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-behaviour-actions-create-name){: #provider-behaviour-actions-create-name .spark-required} | `atom` | | The name of the create action to wire. | + + + + + + + +### provider.behaviour.actions.update +```elixir +update name +``` + + +Marks an update action for instance behaviour wiring + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-behaviour-actions-update-name){: #provider-behaviour-actions-update-name .spark-required} | `atom` | | The name of the update action to wire. | + + + + + + + + + + + + + + + + diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md deleted file mode 100644 index 24cc338..0000000 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ /dev/null @@ -1,530 +0,0 @@ - -# Diffo.Provider.Instance.Extension - -DSL Extension customising an Instance. - -Provides two top-level sections: - -## structure - -Describes the static shape of the Instance kind — what it is, what values it carries, -and what parties it relates to. All structure declarations are baked into the resource -module at compile time via persisters and are introspectable at runtime via -`Diffo.Provider.Instance.Info` or directly as generated functions on the resource module. - -- `specification do` — the TMF Specification (id, name, type, version, description, category). - The id is a stable UUID4 that is the same across all environments for this Instance kind. -- `characteristics do` — typed value slots carried by instances of this kind, each backed - by an `Ash.TypedStruct`. -- `features do` — optional capabilities of this kind, each with its own typed characteristic - payload and an enabled/disabled default. -- `parties do` — the party roles that instances of this kind relate to, with multiplicity, - reference, and calculation options. -- `places do` — the place roles that instances of this kind relate to, mirroring `parties do` - in structure and options. - -## behaviour - -Declares which Ash actions should be wired for instance build lifecycle management. -Currently supports `create` declarations; future sections will cover triggers and other -lifecycle concerns. - -Declaring `create :name` in `behaviour do actions do` causes the `TransformBehaviour` -transformer to inject `:specified_by`, `:features`, and `:characteristics` arguments onto -the named Ash create action. These arguments carry the UUIDs of the TMF entities created -by `build_before/1` and consumed by the Ash relationship management in the action. - -See the [DSL cheat sheet](DSL-Diffo.Provider.Instance.Extension.html) for the full DSL reference. -See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. - - -## structure -Defines the structural shape of the Instance — its specification, characteristics, features, parties, and places - -### Nested DSLs - * [specification](#structure-specification) - * [characteristics](#structure-characteristics) - * characteristic - * [features](#structure-features) - * feature - * characteristic - * [parties](#structure-parties) - * party - * parties - * [places](#structure-places) - * place - * places - - -### Examples -``` -structure do - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - end - - characteristics do - characteristic :circuit, Diffo.Access.Circuit - end - - parties do - party :provider, MyApp.Provider - end - - places do - place :installation_site, MyApp.GeographicSite - end -end - -``` - - - -### structure.specification -Defines the Instance Specification - - - -### Examples -``` -specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - major_version 1 - description "An access network service connecting a subscriber premises to an access NNI via DSL" - category "Network Service" -end - -``` - - - - -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`id`](#structure-specification-id){: #structure-specification-id .spark-required} | `String.t` | | The id of the specification, a uuid4 the same in all environments, unique for name and major_version. | -| [`name`](#structure-specification-name){: #structure-specification-name .spark-required} | `String.t` | | The name of the specification, unique to a service but common for all versions. | -| [`type`](#structure-specification-type){: #structure-specification-type } | `atom` | `:serviceSpecification` | The type of the specification. | -| [`major_version`](#structure-specification-major_version){: #structure-specification-major_version } | `integer` | `1` | The major_version of the specification. | -| [`minor_version`](#structure-specification-minor_version){: #structure-specification-minor_version } | `integer` | | The minor_version of the specification. | -| [`patch_version`](#structure-specification-patch_version){: #structure-specification-patch_version } | `integer` | | The patch_version of the specification. | -| [`tmf_version`](#structure-specification-tmf_version){: #structure-specification-tmf_version } | `integer` | | The TMF API version of the specification, e.g. 4. | -| [`description`](#structure-specification-description){: #structure-specification-description } | `String.t` | | A generic description of the specified service or resource. | -| [`category`](#structure-specification-category){: #structure-specification-category } | `String.t` | | The category the specified service or resource belongs to. | - - - - -### structure.characteristics -List of Instance Characteristics - -### Nested DSLs - * [characteristic](#structure-characteristics-characteristic) - - -### Examples -``` -characteristics do - characteristic :dslam, Diffo.Access.Dslam - characteristic :aggregate_interface, Diffo.Access.AggregateInterface - characteristic :circuit, Diffo.Access.Circuit - characteristic :line, Diffo.Access.Line -end - -``` - - - - -### structure.characteristics.characteristic -```elixir -characteristic name, value_type -``` - - -Adds a Characteristic - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-characteristics-characteristic-name){: #structure-characteristics-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#structure-characteristics-characteristic-value_type){: #structure-characteristics-characteristic-value_type } | `any` | | The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type. | - - - - - - - - -### structure.features -Configuration for Instance Features - -### Nested DSLs - * [feature](#structure-features-feature) - * characteristic - - -### Examples -``` -features do - feature :dynamic_line_management do - is_enabled? true - characteristics do - characteristic :constraints, Diffo.Access.Constraints - end - end -end - -``` - - - - -### structure.features.feature -```elixir -feature name -``` - - -Adds a Feature - -### Nested DSLs - * [characteristic](#structure-features-feature-characteristic) - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-features-feature-name){: #structure-features-feature-name .spark-required} | `atom` | | The name of the feature, an atom | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`is_enabled?`](#structure-features-feature-is_enabled?){: #structure-features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true | - - -### structure.features.feature.characteristic -```elixir -characteristic name, value_type -``` - - -Adds a Characteristic - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-features-feature-characteristic-name){: #structure-features-feature-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#structure-features-feature-characteristic-value_type){: #structure-features-feature-characteristic-value_type } | `any` | | The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type. | - - - - - - - - - - - - -### structure.parties -List of Instance Party roles - -### Nested DSLs - * [party](#structure-parties-party) - * [parties](#structure-parties-parties) - - -### Examples -``` -parties do - party :provider, MyApp.Provider, calculate: :provider_calculation - parties :technician, MyApp.Technician, constraints: [min: 1, max: 3] - party :owner, MyApp.InfrastructureCo, reference: true -end - -``` - - - - -### structure.parties.party -```elixir -party role, party_type -``` - - -Declares a singular party role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-parties-party-role){: #structure-parties-party-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#structure-parties-party-party_type){: #structure-parties-party-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-parties-party-reference){: #structure-parties-party-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#structure-parties-party-calculate){: #structure-parties-party-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the party at build time. | - - - - - -### Introspection - -Target: `Diffo.Provider.Instance.Extension.PartyDeclaration` - -### structure.parties.parties -```elixir -parties role, party_type -``` - - -Declares a plural party role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-parties-parties-role){: #structure-parties-parties-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#structure-parties-parties-party_type){: #structure-parties-parties-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-parties-parties-reference){: #structure-parties-parties-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#structure-parties-parties-calculate){: #structure-parties-parties-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the party at build time. | -| [`constraints`](#structure-parties-parties-constraints){: #structure-parties-parties-constraints } | `keyword` | | Multiplicity constraints on the number of parties in this role, e.g. [min: 1, max: 3] | - - - - - -### Introspection - -Target: `Diffo.Provider.Instance.Extension.PartyDeclaration` - - -### structure.places -List of Instance Place roles - -### Nested DSLs - * [place](#structure-places-place) - * [places](#structure-places-places) - - -### Examples -``` -places do - place :installation_site, MyApp.GeographicSite - places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] - place :billing_address, MyApp.GeographicAddress, reference: true -end - -``` - - - - -### structure.places.place -```elixir -place role, place_type -``` - - -Declares a singular place role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-places-place-role){: #structure-places-place-role .spark-required} | `atom` | | The role name, an atom | -| [`place_type`](#structure-places-place-place_type){: #structure-places-place-place_type } | `any` | | The module of the Place kind. A BasePlace-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-places-place-reference){: #structure-places-place-reference } | `boolean` | `false` | If true, no direct PlaceRef edge is created; the place is reachable by graph traversal. | -| [`calculate`](#structure-places-place-calculate){: #structure-places-place-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the place at build time. | - - - - - -### Introspection - -Target: `Diffo.Provider.Instance.Extension.PlaceDeclaration` - -### structure.places.places -```elixir -places role, place_type -``` - - -Declares a plural place role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-places-places-role){: #structure-places-places-role .spark-required} | `atom` | | The role name, an atom | -| [`place_type`](#structure-places-places-place_type){: #structure-places-places-place_type } | `any` | | The module of the Place kind. A BasePlace-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-places-places-reference){: #structure-places-places-reference } | `boolean` | `false` | If true, no direct PlaceRef edge is created; the place is reachable by graph traversal. | -| [`calculate`](#structure-places-places-calculate){: #structure-places-places-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the place at build time. | -| [`constraints`](#structure-places-places-constraints){: #structure-places-places-constraints } | `keyword` | | Multiplicity constraints on the number of places in this role, e.g. [min: 1, max: 3] | - - - - - -### Introspection - -Target: `Diffo.Provider.Instance.Extension.PlaceDeclaration` - - - - - - -## behaviour -Defines the behavioural wiring for the Instance — actions, and in future triggers and tasks - -### Nested DSLs - * [actions](#behaviour-actions) - * create - * update - - -### Examples -``` -behaviour do - actions do - create :build - update :define - end -end - -``` - - - -### behaviour.actions -Declares which actions to wire for instance behaviour - -### Nested DSLs - * [create](#behaviour-actions-create) - * [update](#behaviour-actions-update) - - -### Examples -``` -actions do - create :build - update :define -end - -``` - - - - -### behaviour.actions.create -```elixir -create name -``` - - -Marks a create action for instance build wiring, injecting :specified_by, :features, and :characteristics arguments - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#behaviour-actions-create-name){: #behaviour-actions-create-name .spark-required} | `atom` | | The name of the create action to wire | - - - - - - - -### behaviour.actions.update -```elixir -update name -``` - - -Marks an update action for instance behaviour wiring - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#behaviour-actions-update-name){: #behaviour-actions-update-name .spark-required} | `atom` | | The name of the update action to wire | - - - - - - - - - - - - - - diff --git a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md deleted file mode 100644 index 643ce53..0000000 --- a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md +++ /dev/null @@ -1,163 +0,0 @@ - -# Diffo.Provider.Party.Extension - -DSL Extension customising a Party. - -Provides compile-time declaration blocks for domain-specific Party kinds -built on `Diffo.Provider.BaseParty`. All declarations are introspectable via -`Diffo.Provider.Party.Extension.Info`. - -See the [DSL cheat sheet](DSL-Diffo.Provider.Party.Extension.html) for the full DSL reference. - - -## instances -Declares the roles this Party kind plays with respect to Instances - -### Nested DSLs - * [role](#instances-role) - - -### Examples -``` -instances do - role :facilitates, MyApp.AccessService -end - -``` - - - - -### instances.role -```elixir -role role, party_type -``` - - -Declares a role this Party kind plays - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#instances-role-role){: #instances-role-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#instances-role-party_type){: #instances-role-party_type } | `any` | | The module of the related resource | - - - - - - -### Introspection - -Target: `Diffo.Provider.Party.Extension.InstanceRole` - - - - -## parties -Declares the roles this Party kind plays with respect to other Parties - -### Nested DSLs - * [role](#parties-role) - - -### Examples -``` -parties do - role :managed_by, MyApp.Person -end - -``` - - - - -### parties.role -```elixir -role role, party_type -``` - - -Declares a role this Party kind plays with respect to other Parties - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#parties-role-role){: #parties-role-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#parties-role-party_type){: #parties-role-party_type } | `any` | | The module of the related Party kind | - - - - - - -### Introspection - -Target: `Diffo.Provider.Party.Extension.PartyRole` - - - - -## places -Declares the roles this Party kind plays with respect to Places - -### Nested DSLs - * [role](#places-role) - - -### Examples -``` -places do - role :headquartered_at, MyApp.GeographicSite -end - -``` - - - - -### places.role -```elixir -role role, place_type -``` - - -Declares a role this Party kind plays with respect to Places - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#places-role-role){: #places-role-role .spark-required} | `atom` | | The role name, an atom | -| [`place_type`](#places-role-place_type){: #places-role-place_type } | `any` | | The module of the related Place resource | - - - - - - -### Introspection - -Target: `Diffo.Provider.Party.Extension.PlaceRole` - - - - - - diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index f44f4e5..7870bde 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -4,12 +4,12 @@ SPDX-FileCopyrightText: 2025 diffo contributors -# Using the Diffo Provider Instance Extension +# Using the Diffo Provider Extension ```elixir Mix.install( [ - {:diffo, "~> 0.2.1"} + {:diffo, "~> 0.3.0"} ], config: [ diffo: [ash_domains: [Diffo.Provider]] @@ -27,19 +27,19 @@ If you are not already familiar with Ash then please explore [Ash Get Started](h First ensure you've explored the Diffo Livebook for an introduction to Diffo: [![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo%2Ddev%2Fdiffo%2Fblob%2Fdev%2Fdiffo.livemd) -In this 'Diffo Provider Instance Extension' livebook you will learn about: +In this livebook you will learn about: * TMF Services and Resources * Building your own Domain -* Declaring a Composite Resource using the Instance Extension -* Using the Assigner +* Declaring Instance resources with the unified `provider do` DSL +* Using the Assigner for partial resource allocation and assignment * Composing a Resource from partially assigned Resources -* Declaring domain Parties using the Party Extension -* Declaring domain Places using the Place Extension +* Declaring Party kinds with `provider do` +* Declaring Place kinds with `provider do` ### Installing Neo4j and Configuring Bolty -Diffo uses the [Ash Neo4j DataLayer](https://github.com/diffo-dev/ash_neo4j), which requires Neo4j to be installed +Diffo uses the [Ash Neo4j DataLayer](https://github.com/diffo-dev/ash_neo4j), which requires Neo4j to be installed. While [Neo4j community edition](https://github.com/neo4j/neo4j) is open source and you can build from source it is likely that you'll use an installation. @@ -110,7 +110,7 @@ When a Provider creates a pool of resources this is known as 'allocation'. For i When a Consumer is leased a resource this is assignment. -Assigment is effectively a request for a relationship from a Provider Resource 'back up' to a Consumer Service or Resource. There are different variants on this: +Assignment is effectively a request for a relationship from a Provider Resource 'back up' to a Consumer Service or Resource. There are different variants on this: * Specific Resource assignment - the specific resource requested by the Consumer is assigned * 'To specification' Resource assignment - an entire resource is assigned by the Provider, allocation may be 'just in time' @@ -121,41 +121,37 @@ In all cases the assignment is only successful if the Provider allows the reques Partial resource assignment uses a relationship characteristic to indicate which part of the resource is optionally requested and ultimately assigned. -## Instance Extension +## Provider Extension -Diffo.Provider.Instance models either a Service or Resource. It actually uses the Diffo.Provider.BaseInstance [Spark.Dsl.Fragment](https://hexdocs.pm/spark/Spark.Dsl.Fragment.html). There is no need to evaluate the Diffo.Provider.Instance below, it is already defined. +`Diffo.Provider.BaseInstance` is an Ash Resource Fragment for domain-specific Instance kinds +(services and resources). It provides a rich set of base attributes — `id`, `href`, `name`, +`type`, `state` and more — plus the unified `Diffo.Provider.Extension` DSL. -```elixir -defmodule Diffo.Provider.Instance do - @moduledoc """ - Ash Resource for a TMF Service or Resource Instance - """ - alias Diffo.Provider.BaseInstance +The extension provides a single `provider do` section containing everything needed to +describe and wire an Instance kind. Declarations are baked into the module at compile time +and introspectable at runtime via generated functions (`specification/0`, `characteristics/0`, +`features/0`, `parties/0`, `places/0`) and `Diffo.Provider.Extension.Info`. - use Ash.Resource, - fragments: [BaseInstance], - domain: Diffo.Provider +The `provider do` section contains: - resource do - description "An Ash Resource for a TMF Service or Resource Instance" - plural_name :instances - end -end -``` +**`specification do`** — the TMF Specification (id, name, type, version, description, category). +The id is a stable UUID4, the same in every environment for this Instance kind. -Diffo also has an inbuilt Spark DSL extension [Diffo.Provider.Instance.Extension](https://hexdocs.pm/diffo/Diffo.Provider.Instance.Extension.html) which provides DSL and functions for use in building and operating domain specific services and resources. +**`characteristics do`** — typed value slots backed by `Ash.TypedStruct`. -The extension has two top-level sections: +**`features do`** — optional capabilities with their own typed characteristic payload. -**`structure do`** — describes the static shape of the Instance kind: its TMF Specification, Characteristics, Features, Party roles, and Place roles. All declarations are baked into the module at compile time and introspectable at runtime via generated functions (`specification/0`, `characteristics/0`, `features/0`, `parties/0`, `places/0`) and `Diffo.Provider.Instance.Info`. +**`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge). -**`behaviour do`** — declares which Ash actions should be wired for instance lifecycle management. Declaring `create :name` injects `:specified_by`, `:features`, and `:characteristics` arguments onto that action, and the `BuildBefore`/`BuildAfter` changes registered on `BaseInstance` automatically handle specification upsert, feature and characteristic creation, party validation, and graph relationship wiring for every create action. You write the action body for your domain-specific accepts and arguments; the structural wiring is handled for you. +**`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference). -Feature and Instance Characteristics can have payloads defined by [Ash.TypedStruct](https://hexdocs.pm/ash/Ash.TypedStruct.html). TypedStruct are DSL specified types which are effectively lightweight embedded resources. We've extended both [AshJason](https://hexdocs.pm/ash_jason/) and [AshOutstanding](https://hexdocs.pm/ash_outstanding/) to support Ash.TypedStruct. +**`behaviour do`** — declares which Ash create actions to wire for build lifecycle management. +Declaring `create :name` injects `:specified_by`, `:features`, and `:characteristics` +arguments automatically onto that action. -For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with an Diffo.Provider.AssignableValue TypedStruct. Allocation is managed within the Provider domain using this characteristic. Assignment to Services or Resources is via 'reverse' type: "assignedTo" relationships enriched by relationship characteristics. +Feature and Instance Characteristics can have payloads defined by [Ash.TypedStruct](https://hexdocs.pm/ash/Ash.TypedStruct.html). TypedStruct are DSL specified types which are effectively lightweight embedded resources. We've extended both [AshJason](https://hexdocs.pm/ash_jason/) and [AshOutstanding](https://hexdocs.pm/ash_outstanding/) to support Ash.TypedStruct. -We can still use the Diffo.Provider API's noting that they will return Diffo.Provider.Instance rather than our specific domain resource, but we'll use our own domain API linked to specific actions. +For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with a `Diffo.Provider.AssignableValue` TypedStruct. Allocation is managed within the Provider domain using this characteristic. Assignment to Services or Resources is via 'reverse' type: "assignedTo" relationships enriched by relationship characteristics. Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. @@ -167,7 +163,7 @@ We'll define all the resources first, then declare the `Diffo.Compute` domain on ### Declaring a Composite Resource -We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the Diffo.Provider.BaseInstance fragment. +We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the `Diffo.Provider.BaseInstance` fragment. ```elixir defmodule Diffo.Compute.Cluster do @@ -192,7 +188,7 @@ defmodule Diffo.Compute.Cluster do plural_name :Clusters end - structure do + provider do specification do id "4bcfc4c9-e776-4878-a658-e8d81857bed7" name "cluster" @@ -213,11 +209,11 @@ defmodule Diffo.Compute.Cluster do places do place :data_centre, Diffo.Compute.DataCentre end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -301,7 +297,7 @@ end ### Using the Assigner -We'll now define a GPU Resource which uses the Diffo.Provider.Assigner functionality. +We'll now define a GPU Resource which uses the `Diffo.Provider.Assigner` functionality. ```elixir defmodule Diffo.Compute.GPU do @@ -327,7 +323,7 @@ defmodule Diffo.Compute.GPU do plural_name :gpus end - structure do + provider do specification do id "ad50073f-17e0-45cb-b9b1-aa4296876156" name "gpu" @@ -340,11 +336,11 @@ defmodule Diffo.Compute.GPU do characteristic :gpu, GPUValue characteristic :cores, AssignableValue end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -435,11 +431,11 @@ end ## Party Extension -`Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `href`, `name`, `type`, `referred_type` — and the `Diffo.Provider.Party.Extension` DSL, which lets a Party kind declare the roles it plays with respect to Instances and other Parties. +`Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `href`, `name`, `type`, `referred_type` — and the unified `Diffo.Provider.Extension` DSL. Within `provider do`, a Party kind uses `instances do`, `parties do`, and `places do` sections to declare the roles it plays. `type` defaults to `:PartyRef` and can be set to `:Individual`, `:Organization`, or `:Entity`. Domain party kinds typically set `type` in their `build` action. The `id` defaults to a generated uuid but can be set to any meaningful string (such as an ABN or a data centre identifier). -The `Diffo.Provider.Party.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Party.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Party.Extension.html). +The `Diffo.Provider.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Extension.html). ### Defining Party kinds @@ -470,9 +466,11 @@ defmodule Diffo.Compute.Tenant do end end - instances do - role :operator, Diffo.Compute.Cluster - role :operator, Diffo.Compute.GPU + provider do + instances do + role :operator, Diffo.Compute.Cluster + role :operator, Diffo.Compute.GPU + end end end ``` @@ -502,24 +500,23 @@ defmodule Diffo.Compute.Engineer do end end - instances do - role :manager, Diffo.Compute.Cluster - end - - parties do - role :employer, Diffo.Compute.Tenant + provider do + instances do + role :manager, Diffo.Compute.Cluster + end + parties do + role :employer, Diffo.Compute.Tenant + end end end ``` ## Place Extension -`Diffo.Provider.BasePlace` is an Ash Resource Fragment for domain-specific Place kinds, mirroring `BaseInstance` and `BaseParty`. It provides common Place attributes — `id`, `href`, `name`, `type`, `referred_type` — and the `Diffo.Provider.Place.Extension` DSL, which lets a Place kind declare the roles it plays with respect to Instances, Parties, and other Places. +`Diffo.Provider.BasePlace` is an Ash Resource Fragment for domain-specific Place kinds, mirroring `BaseInstance` and `BaseParty`. It provides common Place attributes — `id`, `href`, `name`, `type`, `referred_type` — and the unified `Diffo.Provider.Extension` DSL. Within `provider do`, a Place kind uses `instances do`, `parties do`, and `places do` sections to declare the roles it plays. `type` defaults to `:PlaceRef` and is typically set in the `build` action to the concrete place type (`:GeographicSite`, `:GeographicLocation`, or `:GeographicAddress`). When `referred_type` is present, `type` must be `:PlaceRef` — meaning this Place is a reference rather than a physical location. -The `Diffo.Provider.Place.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Place.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Place.Extension.html). - ### Defining Place kinds We'll add a `DataCentre` Place kind to our Compute domain. Clusters are hosted at a data centre; the `instances do` block records that relationship from the DataCentre's perspective. @@ -559,9 +556,11 @@ defmodule Diffo.Compute.DataCentre do end end - instances do - role :data_centre, Diffo.Compute.Cluster - role :data_centre, Diffo.Compute.GPU + provider do + instances do + role :data_centre, Diffo.Compute.Cluster + role :data_centre, Diffo.Compute.GPU + end end end ``` @@ -738,10 +737,14 @@ What happens when I request a specific assignment from an instance to which the ### What Next? -In this tutorial you've used Diffo's Provider Instance Extension to define a Compute domain with a composite Cluster resource comprised of assigned GPU cores, the Provider Party Extension to define Tenant and Engineer party kinds that operate and manage those resources, and the Provider Place Extension to declare where instances and parties exist geographically. +In this tutorial you've used Diffo's unified `provider do` extension to define a Compute domain with: + +- A composite Cluster resource with GPU core assignment via `Diffo.Provider.Assigner` +- `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage +- A `DataCentre` Place kind that declares the instances located at it -`BaseParty` and `BasePlace` follow the same pattern as `BaseInstance` — domain-specific resources use them as fragments and write their own actions for domain-specific attributes. No manual wiring is needed. +`BaseParty` and `BasePlace` follow the same `provider do` pattern as `BaseInstance` — domain-specific resources use them as fragments, write their own actions for domain-specific attributes, and declare their roles via the unified DSL sections. -Domain-specific Place kinds (such as a DataCentre with its own attributes) use `BasePlace` as a fragment and declare their roles via `instances do`, `parties do`, and `places do` sections on `Diffo.Provider.Place.Extension`. Party kinds similarly declare their place roles via `places do` on `Diffo.Provider.Party.Extension`. +The full DSL reference is at [DSL-Diffo.Provider.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Extension.html). If you find Diffo useful please visit and star on [github](https://github.com/diffo-dev/diffo/). Feel free to join discussions and raise issues to discuss PR's. diff --git a/mix.exs b/mix.exs index 75a612e..8efb173 100644 --- a/mix.exs +++ b/mix.exs @@ -68,17 +68,13 @@ defmodule Diffo.MixProject do "README.md": [title: "Guide"], "LICENSES/MIT.md": [title: "License"], "diffo.livemd": [title: "Tutorial"], - "documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md": [ - title: "DSL: Diffo.Provider.Instance.Extension", - search_data: Spark.Docs.search_data_for(Diffo.Provider.Instance.Extension) - ], - "documentation/dsls/DSL-Diffo.Provider.Party.Extension.md": [ - title: "DSL: Diffo.Provider.Party.Extension", - search_data: Spark.Docs.search_data_for(Diffo.Provider.Party.Extension) + "documentation/dsls/DSL-Diffo.Provider.Extension.md": [ + title: "DSL: Diffo.Provider.Extension", + search_data: Spark.Docs.search_data_for(Diffo.Provider.Extension) ], "documentation/how_to/use_diffo_type.livemd": [title: "Using Diffo.Type"], "documentation/how_to/use_diffo_provider_extension.livemd": [ - title: "Using the Diffo Provider Instance Extension" + title: "Using the Diffo Provider Extension" ], "documentation/how_to/use_diffo_provider_versioning.livemd": [ title: "Instance Versioning with the Diffo Provider" @@ -148,9 +144,9 @@ defmodule Diffo.MixProject do "spark.replace_doc_links" ], "spark.cheat_sheets": - "spark.cheat_sheets --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", + "spark.cheat_sheets --extensions Diffo.Provider.Extension", "spark.formatter": [ - "spark.formatter --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", + "spark.formatter --extensions Diffo.Provider.Extension", "format .formatter.exs" ] ] diff --git a/usage-rules.md b/usage-rules.md index 0afb07d..2a9e034 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -10,17 +10,20 @@ SPDX-License-Identifier: MIT Diffo is an Ash Framework layer that models [TM Forum](https://www.tmforum.org/) (TMF) Service and Resource Management domains on top of a Neo4j graph database. It provides three base -fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the `Diffo.Provider.Instance.Extension` -and `Diffo.Provider.Party.Extension` DSLs. Read these rules and the Ash/AshNeo4j usage rules -**before** writing any domain code. +fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the unified `Diffo.Provider.Extension` +DSL. Read these rules and the Ash/AshNeo4j usage rules **before** writing any domain code. ## The three kinds of domain resource -| Kind | Base fragment | DSL extension | +| Kind | Base fragment | Marker extension | |---|---|---| | Instance (service or resource) | `Diffo.Provider.BaseInstance` | `Diffo.Provider.Instance.Extension` | | Party (organisation, person, entity) | `Diffo.Provider.BaseParty` | `Diffo.Provider.Party.Extension` | -| Place (site, address, location) | `Diffo.Provider.BasePlace` | `Diffo.Provider.Party.Extension` | +| Place (site, address, location) | `Diffo.Provider.BasePlace` | `Diffo.Provider.Place.Extension` | + +All three kinds use the same unified `Diffo.Provider.Extension` DSL with a single `provider do` +section. The marker extensions are zero-section extensions used only for kind identification +via `Ash.Resource.Info.extensions/1` — they carry no DSL of their own. Do **not** use plain `Ash.Resource` + `AshNeo4j.DataLayer` directly for domain resources. Always start from the appropriate base fragment: @@ -32,19 +35,25 @@ defmodule MyApp.BroadbandService do end ``` -## Instance Extension DSL +## The unified `provider do` DSL + +All DSL declarations live inside a single `provider do` block. The sections available +depend on the resource kind: + +- **Instance** — `specification`, `characteristics`, `features`, `parties`, `places`, `behaviour` +- **Party** — `instances`, `parties`, `places` +- **Place** — `instances`, `parties`, `places` -Every resource using `BaseInstance` gains two top-level DSL sections: `structure do` and -`behaviour do`. +Verifiers enforce that each kind uses only the sections relevant to it. -### structure +### `specification do` — Instance only -`specification do` — declares the TMF Specification for this Instance kind. The `id` is a -**stable UUID4 that must be the same in every environment** — generate it once and never -change it. A new major version requires a new module with a new `id`. +Declares the TMF Specification for this Instance kind. The `id` is a **stable UUID4 that +must be the same in every environment** — generate it once and never change it. A new major +version requires a new module with a new `id`. ```elixir -structure do +provider do specification do id "da9b207a-26c3-451d-8abd-0640c6349979" name "DSL Access Service" @@ -56,64 +65,123 @@ structure do end ``` -`characteristics do` — declares typed value slots. Each characteristic is backed by an -`Ash.TypedStruct`. Do **not** add plain Ash attributes for data that belongs in a characteristic. +### `characteristics do` — Instance only + +Declares typed value slots. Each characteristic is backed by an `Ash.TypedStruct`. Do **not** +add plain Ash attributes for data that belongs in a characteristic. ```elixir -characteristics do - characteristic :downstream_speed, MyApp.Speed - characteristic :access_technology, MyApp.AccessTechnology +provider do + characteristics do + characteristic :downstream_speed, MyApp.Speed + characteristic :access_technology, MyApp.AccessTechnology + characteristic :ports, {:array, MyApp.Port} + end end ``` -`features do` — declares optional capabilities, each with an enabled/disabled default and -optionally its own typed characteristic payload: +### `features do` — Instance only + +Declares optional capabilities, each with an enabled/disabled default and optionally its +own typed characteristic payload. ```elixir -features do - feature :voice, is_enabled?: false - feature :static_ip, is_enabled?: false do - characteristic :ip_address, MyApp.IpAddress +provider do + features do + feature :voice, is_enabled?: false + feature :static_ip, is_enabled?: false do + characteristic :ip_address, MyApp.IpAddress + end end end ``` -`parties do` — declares party roles. Use `party` for singular (at most one) and `parties` -for plural relationships: +### `parties do` — all kinds, different keywords per kind + +**For Instance kinds** use `party`, `parties`, and `party_ref`: ```elixir -parties do - party :provider, MyApp.RSP - parties :installer, MyApp.Engineer, constraints: [min: 1, max: 3] - party :owner, MyApp.Organization, reference: true - party :operator, MyApp.RSP, calculate: :derive_operator +provider do + parties do + party :provider, MyApp.RSP # singular, direct edge + parties :installer, MyApp.Engineer, constraints: [min: 1, max: 3] # plural + party_ref :owner, MyApp.Organization # reference — no direct edge + party :operator, MyApp.RSP, calculate: :derive_operator # calculated + end end ``` -- `reference: true` — no direct `PartyRef` edge is created; the party is reachable by graph - traversal. Do not add a `PartyRef` relationship manually when `reference: true` is set. +- `party` — singular (at most one); creates a `PartyRef` edge on build. +- `parties` — plural; accepts `constraints: [min: n, max: m]`. +- `party_ref` — no direct `PartyRef` edge is created; the party is reachable by graph + traversal. Do not add a `PartyRef` relationship manually when `party_ref` is declared. - `calculate:` — names an Ash calculation on this resource that produces the party struct at - build time. The calculation runs inside `build_before/1`; do not call it manually. + build time. Runs inside `build_before/1`; do not call it manually. -`places do` — mirrors `parties do` in structure and options: +**For Party and Place kinds** use `role`: ```elixir -places do - place :installation_site, MyApp.GeographicSite - places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] +provider do + parties do + role :employer, MyApp.Organization + end end ``` -### behaviour +### `places do` — all kinds, different keywords per kind -`behaviour do actions do create :name end end` — marks a named create action for build -wiring. This injects the `:specified_by`, `:features`, and `:characteristics` Ash action -arguments automatically. Do **not** declare these arguments in the action body. +Mirrors `parties do` in structure. For Instance kinds: `place`, `places`, `place_ref`. +For Party/Place kinds: `role`. ```elixir -behaviour do - actions do - create :build +# Instance +provider do + places do + place :installation_site, MyApp.GeographicSite + places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] + place_ref :billing_address, MyApp.GeographicAddress + end +end + +# Party or Place +provider do + places do + role :headquarters, MyApp.GeographicSite + end +end +``` + +### `instances do` — Party and Place only + +Declares the Instance kinds this Party or Place kind plays a role with respect to. +Use `role` for a direct relationship, `instance_ref` for a reference (no direct edge). + +```elixir +provider do + instances do + role :provider, MyApp.BroadbandService + role :provider, MyApp.VoiceService + instance_ref :manages, MyApp.InternalService + end +end +``` + +Role names are domain nouns from the party's/place's perspective — timeless, +`snake_case` atoms. Use `camelCase` atoms for multi-word names that follow TMF +conventions (e.g. `:dataCentre`, not `:data_centre`). + +### `behaviour do` — Instance only + +Marks a named create action for build wiring. Declaring `create :name` injects the +`:specified_by`, `:features`, and `:characteristics` Ash action arguments automatically. +Do **not** declare these arguments in the action body. + +```elixir +provider do + behaviour do + actions do + create :build + end end end ``` @@ -134,6 +202,21 @@ functions: They are wired to every create action via global `BuildBefore` and `BuildAfter` changes on `BaseInstance`. +## Runtime introspection + +Use `Diffo.Provider.Extension.Info` to introspect any provider resource at runtime: + +```elixir +Diffo.Provider.Extension.Info.provider_parties(MyApp.BroadbandService) +Diffo.Provider.Extension.Info.provider_places(MyApp.BroadbandService) +Diffo.Provider.Extension.Info.provider_instances(MyApp.RSP) +Diffo.Provider.Extension.Info.instance?(MyApp.BroadbandService) # true +Diffo.Provider.Extension.Info.party?(MyApp.RSP) # true +``` + +The old `Instance.Extension.Info`, `Party.Extension.Info`, and `Place.Extension.Info` +modules are still available as thin delegating wrappers for backwards compatibility. + ## Instance versioning - **Minor/patch version bumps** — update `minor_version` or `patch_version` in `specification do`. @@ -143,31 +226,115 @@ They are wired to every create action via global `BuildBefore` and `BuildAfter` - **Never change the `id`** of an existing specification. It is a stable cross-environment identity; changing it orphans existing instances. -## Party and Place resources - -Party and Place resources use `BaseParty`/`BasePlace` and the Party Extension DSL to declare -the Instance and Party roles they participate in: +## Complete example ```elixir +# Instance resource +defmodule MyApp.BroadbandService do + use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.Domain + + resource do + description "An ADSL broadband service" + plural_name :broadband_services + end + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "broadbandService" + type :serviceSpecification + major_version 1 + category "Network Service" + end + + characteristics do + characteristic :circuit, MyApp.CircuitValue + end + + parties do + party :provider, MyApp.RSP + party_ref :owner, MyApp.Organization + end + + places do + place :installation_site, MyApp.GeographicSite + end + + behaviour do + actions do + create :build + end + end + end + + actions do + create :build do + accept [:name] + argument :parties, {:array, :struct} + argument :places, {:array, :struct} + end + end +end + +# Party resource defmodule MyApp.RSP do use Ash.Resource, fragments: [Diffo.Provider.BaseParty], domain: MyApp.Domain - instances do - role :provider, MyApp.BroadbandService - role :provider, MyApp.VoiceService + resource do + description "A Retail Service Provider" + plural_name :rsps end - parties do - role :employer, MyApp.Organization + provider do + instances do + role :provider, MyApp.BroadbandService + end + parties do + role :employer, MyApp.Organization + end + end + + actions do + create :build do + accept [:id, :name] + change set_attribute(:type, :Organization) + end end end -``` -Role names are domain nouns from the party's perspective — timeless, `camelCase` when -multi-word (e.g. `:dataCentre`, not `:data_centre`). +# Place resource +defmodule MyApp.GeographicSite do + use Ash.Resource, fragments: [Diffo.Provider.BasePlace], domain: MyApp.Domain + + resource do + description "A geographic site" + plural_name :geographic_sites + end + + provider do + instances do + role :installation_site, MyApp.BroadbandService + end + parties do + role :managed_by, MyApp.RSP + end + end + + actions do + create :build do + accept [:id, :name] + change set_attribute(:type, :GeographicSite) + end + end +end +``` ## Common mistakes +- **Do not use `structure do` or top-level `instances do`/`parties do`/`places do`** — these + are the old pre-0.3.0 syntax. All declarations belong inside `provider do`. +- **Do not use `party :role, Type, reference: true`** — use `party_ref :role, Type` instead. +- **Do not use `place :role, Type, reference: true`** — use `place_ref :role, Type` instead. - **Do not add raw Ash attributes for TMF-modelled data** — use `characteristics`, `features`, `parties`, and `places` in the DSL instead. - **Do not declare `:specified_by`, `:features`, or `:characteristics` Ash action arguments** @@ -177,5 +344,3 @@ multi-word (e.g. `:dataCentre`, not `:data_centre`). managed entirely by the `build_before/1` generated function. - **Do not use `party/1` in place of `parties/3`** (and vice versa) — `party` declares a singular role; `parties` declares a plural role. Mismatching causes compile-time errors. -- **Do not set a `referred_type` without also setting `type: :PartyRef`** — TMF requires - both fields when using a party reference. From 1f2d27986933b0abb94ed1be80add318369a7d55 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 16:47:39 +0930 Subject: [PATCH 3/3] improve test coverage --- test/provider/extension/info_test.exs | 76 +++++++++ .../extension/instance_verifier_test.exs | 152 ++++++++++++++++++ .../extension/party_verifier_test.exs | 50 +++++- .../extension/place_verifier_test.exs | 50 +++++- 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 test/provider/extension/info_test.exs diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs new file mode 100644 index 0000000..d54e039 --- /dev/null +++ b/test/provider/extension/info_test.exs @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InfoTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Provider.Extension.Info + + describe "instance?/1" do + test "returns true for a BaseInstance-derived resource" do + assert Info.instance?(Diffo.Test.Shelf) == true + end + + test "returns true for the base Instance resource" do + assert Info.instance?(Diffo.Provider.Instance) == true + end + + test "returns false for a BaseParty-derived resource" do + assert Info.instance?(Diffo.Test.Organization) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.instance?(Diffo.Test.GeographicSite) == false + end + + test "returns false for a non-existent module" do + assert Info.instance?(NonExistent.Module) == false + end + end + + describe "party?/1" do + test "returns true for a BaseParty-derived resource" do + assert Info.party?(Diffo.Test.Organization) == true + end + + test "returns true for the base Party resource" do + assert Info.party?(Diffo.Provider.Party) == true + end + + test "returns false for a BaseInstance-derived resource" do + assert Info.party?(Diffo.Test.Shelf) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.party?(Diffo.Test.GeographicSite) == false + end + + test "returns false for a non-existent module" do + assert Info.party?(NonExistent.Module) == false + end + end + + describe "place?/1" do + test "returns true for a BasePlace-derived resource" do + assert Info.place?(Diffo.Test.GeographicSite) == true + end + + test "returns true for the base Place resource" do + assert Info.place?(Diffo.Provider.Place) == true + end + + test "returns false for a BaseInstance-derived resource" do + assert Info.place?(Diffo.Test.Shelf) == false + end + + test "returns false for a BaseParty-derived resource" do + assert Info.place?(Diffo.Test.Organization) == false + end + + test "returns false for a non-existent module" do + assert Info.place?(NonExistent.Module) == false + end + end +end diff --git a/test/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index dc2dba4..19a3d7f 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -462,5 +462,157 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end ) end + + test "create declared for an update action warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "behaviour: create :define does not exist as a create action on this resource", + fn -> + defmodule BehaviourWrongActionType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with create behaviour pointing at an update action" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + behaviour do + actions do + create :define + end + end + end + + actions do + update :define do + accept [] + end + end + end + end + ) + end + end + + describe "party_ref verifier" do + test "non-existent party_type on party_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type NonExistent.RefParty does not exist", + fn -> + defmodule InvalidPartyRefType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with party_ref pointing to a non-existent module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party_ref :owner, NonExistent.RefParty + end + end + end + end + ) + end + + test "party_ref with non-BaseParty type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type Diffo.Test.Shelf does not extend BaseParty", + fn -> + defmodule InvalidPartyRefBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with party_ref pointing to a non-party module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party_ref :owner, Diffo.Test.Shelf + end + end + end + end + ) + end + end + + describe "place_ref verifier" do + test "non-existent place_type on place_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type NonExistent.RefPlace does not exist", + fn -> + defmodule InvalidPlaceRefType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with place_ref pointing to a non-existent module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + places do + place_ref :billing, NonExistent.RefPlace + end + end + end + end + ) + end + + test "place_ref with non-BasePlace type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type Diffo.Test.Shelf does not extend BasePlace", + fn -> + defmodule InvalidPlaceRefBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with place_ref pointing to a non-place module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + places do + place_ref :billing, Diffo.Test.Shelf + end + end + end + end + ) + end end end diff --git a/test/provider/extension/party_verifier_test.exs b/test/provider/extension/party_verifier_test.exs index 505dc32..ac4fd3b 100644 --- a/test/provider/extension/party_verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -204,7 +204,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do Spark.Error.DslError, "places: place_type Diffo.Test.Organization does not extend BasePlace", fn -> - defmodule WrongPlaceRoleType do + defmodule WrongPartyPlaceRoleType do alias Diffo.Provider.BaseParty use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn @@ -222,4 +222,52 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do ) end end + + describe "instance_ref verifier" do + test "non-existent instance_type on instance_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.RefInstance does not exist", + fn -> + defmodule InvalidPartyInstanceRefType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "party with instance_ref pointing to a non-existent module" + end + + provider do + instances do + instance_ref :manages, NonExistent.RefInstance + end + end + end + end + ) + end + + test "instance_ref with non-BaseInstance type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Organization does not extend BaseInstance", + fn -> + defmodule InvalidPartyInstanceRefBaseType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "party with instance_ref pointing to a non-instance module" + end + + provider do + instances do + instance_ref :manages, Diffo.Test.Organization + end + end + end + end + ) + end + end end diff --git a/test/provider/extension/place_verifier_test.exs b/test/provider/extension/place_verifier_test.exs index 232e21a..d57bc05 100644 --- a/test/provider/extension/place_verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -204,7 +204,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do Spark.Error.DslError, "places: place_type Diffo.Test.Organization does not extend BasePlace", fn -> - defmodule WrongPlacePlaceType do + defmodule WrongPlacePlaceRoleType do alias Diffo.Provider.BasePlace use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn @@ -222,4 +222,52 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do ) end end + + describe "instance_ref verifier" do + test "non-existent instance_type on instance_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.RefInstance does not exist", + fn -> + defmodule InvalidPlaceInstanceRefType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with instance_ref pointing to a non-existent module" + end + + provider do + instances do + instance_ref :site_for, NonExistent.RefInstance + end + end + end + end + ) + end + + test "instance_ref with non-BaseInstance type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Organization does not extend BaseInstance", + fn -> + defmodule InvalidPlaceInstanceRefBaseType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with instance_ref pointing to a non-instance module" + end + + provider do + instances do + instance_ref :site_for, Diffo.Test.Organization + end + end + end + end + ) + end + end end