diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md index bf9d55b..6b5c7d3 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md @@ -5,15 +5,77 @@ This file was generated by Spark. Do not edit it by hand. DSL Extension customising an Instance. -Provides compile-time declaration blocks for domain-specific Service and Resource kinds -built on `Diffo.Provider.BaseInstance`. All declarations are introspectable via -`Diffo.Provider.Instance.Extension.Info`. +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. + +## 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. +See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. + + +## structure +Defines the structural shape of the Instance — its specification, characteristics, features, and parties + +### Nested DSLs + * [specification](#structure-specification) + * [characteristics](#structure-characteristics) + * characteristic + * [features](#structure-features) + * feature + * characteristic + * [parties](#structure-parties) + * party + * parties + + +### 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 +end + +``` + -## specification +### structure.specification Defines the Instance Specification @@ -38,23 +100,68 @@ end | Name | Type | Default | Docs | |------|------|---------|------| -| [`id`](#specification-id){: #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`](#specification-name){: #specification-name .spark-required} | `String.t` | | The name of the specification, unique to a service but common for all versions. | -| [`type`](#specification-type){: #specification-type } | `atom` | `:serviceSpecification` | The type of the specification. | -| [`major_version`](#specification-major_version){: #specification-major_version } | `integer` | `1` | The major_version of the specification. | -| [`description`](#specification-description){: #specification-description } | `String.t` | | A generic description of the specified service or resource. | -| [`category`](#specification-category){: #specification-category } | `String.t` | | The category the specified service or resource belongs to. | +| [`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. | +| [`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. | -## features + + +### structure.features Configuration for Instance Features ### Nested DSLs - * [feature](#features-feature) + * [feature](#structure-features-feature) * characteristic @@ -74,7 +181,7 @@ end -### features.feature +### structure.features.feature ```elixir feature name ``` @@ -83,7 +190,7 @@ feature name Adds a Feature ### Nested DSLs - * [characteristic](#features-feature-characteristic) + * [characteristic](#structure-features-feature-characteristic) @@ -92,15 +199,15 @@ Adds a Feature | Name | Type | Default | Docs | |------|------|---------|------| -| [`name`](#features-feature-name){: #features-feature-name .spark-required} | `atom` | | The name of the feature, an atom | +| [`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?`](#features-feature-is_enabled?){: #features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true | +| [`is_enabled?`](#structure-features-feature-is_enabled?){: #structure-features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true | -### features.feature.characteristic +### structure.features.feature.characteristic ```elixir characteristic name, value_type ``` @@ -116,9 +223,8 @@ Adds a Characteristic | Name | Type | Default | Docs | |------|------|---------|------| -| [`name`](#features-feature-characteristic-name){: #features-feature-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#features-feature-characteristic-value_type){: #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. | - +| [`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. | @@ -131,21 +237,20 @@ Adds a Characteristic - -## characteristics -List of Instance Characteristics +### structure.parties +List of Instance Party roles ### Nested DSLs - * [characteristic](#characteristics-characteristic) + * [party](#structure-parties-party) + * [parties](#structure-parties-parties) ### Examples ``` -characteristics do - characteristic :dslam, Diffo.Access.Dslam - characteristic :aggregate_interface, Diffo.Access.AggregateInterface - characteristic :circuit, Diffo.Access.Circuit - characteristic :line, Diffo.Access.Line +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 ``` @@ -153,13 +258,13 @@ end -### characteristics.characteristic +### structure.parties.party ```elixir -characteristic name, value_type +party role, party_type ``` -Adds a Characteristic +Declares a singular party role on this Instance @@ -169,46 +274,111 @@ Adds a Characteristic | Name | Type | Default | Docs | |------|------|---------|------| -| [`name`](#characteristics-characteristic-name){: #characteristics-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#characteristics-characteristic-value_type){: #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. | +| [`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` -## parties -List of Instance Party roles + + + + +## behaviour +Defines the behavioural wiring for the Instance — actions, and in future triggers and tasks ### Nested DSLs - * [party](#parties-party) - * [parties](#parties-parties) + * [actions](#behaviour-actions) + * create + * update ### 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 +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 + +``` + -### parties.party + + +### behaviour.actions.create ```elixir -party role, party_type +create name ``` -Declares a singular party role on this Instance +Marks a create action for instance build wiring, injecting :specified_by, :features, and :characteristics arguments @@ -218,30 +388,21 @@ Declares a singular party role on this Instance | Name | Type | Default | Docs | |------|------|---------|------| -| [`role`](#parties-party-role){: #parties-party-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#parties-party-party_type){: #parties-party-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options +| [`name`](#behaviour-actions-create-name){: #behaviour-actions-create-name .spark-required} | `atom` | | The name of the create action to wire | -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#parties-party-reference){: #parties-party-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#parties-party-calculate){: #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` -### parties.parties +### behaviour.actions.update ```elixir -parties role, party_type +update name ``` -Declares a plural party role on this Instance +Marks an update action for instance behaviour wiring @@ -251,23 +412,15 @@ Declares a plural party role on this Instance | Name | Type | Default | Docs | |------|------|---------|------| -| [`role`](#parties-parties-role){: #parties-parties-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#parties-parties-party_type){: #parties-parties-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options +| [`name`](#behaviour-actions-update-name){: #behaviour-actions-update-name .spark-required} | `atom` | | The name of the update action to wire | + -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#parties-parties-reference){: #parties-parties-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#parties-parties-calculate){: #parties-parties-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the party at build time. | -| [`constraints`](#parties-parties-constraints){: #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` diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 974a475..587fbfd 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -144,7 +144,12 @@ end 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. -Currently it has DSL to allow you to declare specification, features, characteristics, and party roles. It can be used for services or resources. +The extension has two top-level sections: + +**`structure do`** — describes the static shape of the Instance kind: its TMF Specification, Characteristics, Features, and Party 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`) and `Diffo.Provider.Instance.Info`. + +**`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. + 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. 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. @@ -172,7 +177,6 @@ defmodule Diffo.Compute.Cluster do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.ActionHelper alias Diffo.Compute alias Diffo.Compute.ClusterValue alias Diffo.Compute.Tenant @@ -187,42 +191,40 @@ defmodule Diffo.Compute.Cluster do plural_name :Clusters end - specification do - id "4bcfc4c9-e776-4878-a658-e8d81857bed7" - name "cluster" - type :resourceSpecification - description "A Cluster Resource Instance" - category "Network Resource" - end + structure do + specification do + id "4bcfc4c9-e776-4878-a658-e8d81857bed7" + name "cluster" + type :resourceSpecification + description "A Cluster Resource Instance" + category "Network Resource" + end + + characteristics do + characteristic :cluster, ClusterValue + end - characteristics do - characteristic :cluster, ClusterValue + parties do + party :operator, Tenant + party :manager, Engineer + end end - parties do - party :operator, Tenant - party :manager, Engineer + behaviour do + actions do + create :build + end end actions do create :build do description "creates a new Cluster resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false argument :places, {:array, :struct} argument :parties, {:array, :struct} change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> ActionHelper.build_before(changeset) end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after(changeset, result, Compute, :get_cluster_by_id) - end) - change load [:href] upsert? false end @@ -232,7 +234,7 @@ defmodule Diffo.Compute.Cluster do argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> - with {:ok, _result} <- Characteristic.update_values(result, changeset), + with {:ok, result} <- Characteristic.update_values(result, changeset), {:ok, cluster} <- Compute.get_cluster_by_id(result.id), do: {:ok, cluster} end) @@ -305,7 +307,6 @@ defmodule Diffo.Compute.GPU do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.ActionHelper alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -321,38 +322,36 @@ defmodule Diffo.Compute.GPU do plural_name :gpus end - specification do - id "ad50073f-17e0-45cb-b9b1-aa4296876156" - name "gpu" - type :resourceSpecification - description "A GPU Resource Instance" - category "Network Resource" + structure do + specification do + id "ad50073f-17e0-45cb-b9b1-aa4296876156" + name "gpu" + type :resourceSpecification + description "A GPU Resource Instance" + category "Network Resource" + end + + characteristics do + characteristic :gpu, GPUValue + characteristic :cores, AssignableValue + end end - characteristics do - characteristic :gpu, GPUValue - characteristic :cores, AssignableValue + behaviour do + actions do + create :build + end end actions do create :build do description "creates a new GPU resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false argument :places, {:array, :struct} argument :parties, {:array, :struct} change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> ActionHelper.build_before(changeset) end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after(changeset, result, Compute, :get_gpu_by_id) - end) - change load [:href] upsert? false end @@ -685,7 +684,7 @@ What happens when I request a specific assignment from an instance to which the 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, and the Provider Party Extension to define Tenant and Engineer party kinds that operate and manage those resources. -The BaseParty fragment follows the same pattern as BaseInstance — domain-specific resources use it as a fragment and finish their actions with a domain-scoped reload to pick up extended fields. +`BaseParty` follows the same pattern as `BaseInstance` — domain-specific party resources use it as a fragment and write their own `build` action for domain-specific attributes. No manual wiring is needed. A `BasePlace` extension for domain-specific Place kinds (such as a DataCentre with its own attributes) follows the same pattern and will be added in a future release. diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 6fcb5cd..d927cb4 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -4,7 +4,7 @@ defmodule Diffo.Provider.BaseInstance do @moduledoc """ - Ash Resource Fragment which is a the point of extension for your TMF Service or Resource Instance + Ash Resource Fragment which is the point of extension for your TMF Service or Resource Instance. `BaseInstance` is the foundation for domain-specific Service and Resource kinds. Include it as a fragment on an `Ash.Resource` to get common Instance attributes, @@ -12,19 +12,22 @@ defmodule Diffo.Provider.BaseInstance do ## Instance Extension DSL - The `Diffo.Provider.Instance.Extension` DSL provides compile-time declaration blocks - for describing the shape of a domain-specific Service or Resource. + The DSL has two top-level sections: `structure do` describes what the instance kind is; + `behaviour do` wires it to Ash actions. - `specification do` — declares the TMF Specification for this Instance kind. + ### structure - `features do` — declares the Features this Instance kind may have, each optionally - carrying a typed characteristic payload. + `specification do` — declares the TMF Specification for this Instance kind (id, name, type, + major_version, description, category). - `characteristics do` — declares the top-level Characteristics of this Instance kind, - each backed by an `Ash.TypedStruct`. + `characteristics do` — declares the top-level Characteristics of this Instance kind, each + backed by an `Ash.TypedStruct`. + + `features do` — declares the Features this Instance kind may have, each optionally carrying + its own typed characteristic payload. `parties do` — declares the Party roles this Instance kind relates to. Role names are - domain-specific nouns describing what the party is to the instance. Two forms: + domain-specific nouns describing what the party means to the instance. Two forms: parties do party :provider, MyApp.Provider, calculate: :provider_calculation @@ -33,12 +36,44 @@ defmodule Diffo.Provider.BaseInstance do party :owner, MyApp.InfrastructureCo, reference: true end - - `party` — singular (at most one party in this role) - - `parties` — plural (unbounded, or bounded with `constraints:`) + - `party` — singular (at most one party in this role per instance) + - `parties` — plural (unbounded, or bounded with `constraints: [min: n, max: m]`) - `reference: true` — no direct `PartyRef` edge; party is reachable by graph traversal - `calculate:` — names an Ash calculation on this resource that produces the party at build time - All declarations are introspectable via `Diffo.Provider.Instance.Extension.Info`. + All declarations are introspectable at runtime via `Diffo.Provider.Instance.Info` and at + compile time via `Diffo.Provider.Instance.Extension.Info`. + + ### behaviour + + `behaviour do actions do create :name end end` — marks a named create action for build + wiring. This injects `:specified_by`, `:features`, and `:characteristics` arguments onto + that action so Ash accepts the values that `build_before/1` sets automatically. + + You still write the action body yourself for domain-specific accepts, arguments, and changes. + The build arguments are not public and do not need to appear in `accept`. + + ## Generated functions + + Every resource using `BaseInstance` with a `specification do` gets the following functions + generated at compile time: + + - `specification/0` — the specification keyword list baked at compile time + - `characteristics/0` — list of `Characteristic` structs + - `features/0` — list of `Feature` structs + - `parties/0` — list of `PartyDeclaration` structs + - `characteristic/1` — returns the named `Characteristic` or `nil` + - `feature/1` — returns the named `Feature` or `nil` + - `feature_characteristic/2` — returns the named characteristic within a feature, or `nil` + - `party/1` — returns the `PartyDeclaration` for the given role, or `nil` + - `build_before/1` — called automatically before every create action; upserts the + specification and creates features, characteristics, and parties, setting their ids + as action arguments + - `build_after/2` — called automatically after every create action; relates the created + TMF entities to the new instance node + + Resources without a `specification do id` get trivial passthroughs for `build_before/1` + and `build_after/2`. ## Usage @@ -50,28 +85,50 @@ defmodule Diffo.Provider.BaseInstance do plural_name :clusters end - specification do - id "4bcfc4c9-e776-4878-a658-e8d81857bed7" - name "cluster" - type :resourceSpecification + structure do + specification do + id "4bcfc4c9-e776-4878-a658-e8d81857bed7" + name "cluster" + type :resourceSpecification + end + + parties do + party :operator, MyApp.Organization + parties :installer, MyApp.Engineer + end + end + + behaviour do + actions do + create :build + end end - parties do - party :operator, MyApp.Organization - parties :installer, MyApp.Engineer + actions do + create :build do + description "creates a new Cluster resource instance" + accept [:id, :name, :type, :which] + argument :relationships, {:array, :struct} + argument :parties, {:array, :struct} + + change set_attribute(:type, :resource) + change load [:href] + upsert? false + end end end - ## Action pattern + ## Rolling your own actions - Domain-specific Instance resources should finish their `build` action with a reload via - their own domain's `get_xxx_by_id` to pick up extended fields: + The `behaviour do actions do create :name end end` declaration is optional. Omitting it + means the `:specified_by`, `:features`, and `:characteristics` arguments are not declared + on that action — but `build_before/1` and `build_after/2` are still called for every + create via the global `BuildBefore` and `BuildAfter` changes registered on `BaseInstance`. - create :build do - change after_action(fn changeset, result, _context -> - ActionHelper.build_after(changeset, result, MyApp.Domain, :get_cluster_by_id) - end) - end + If you have a create action that should NOT trigger the full build wiring (e.g. a + lightweight admin create), you can override `build_before/1` or `build_after/2` on your + resource, or use Ash's `skip_unknown_inputs` to absorb the injected arguments without + declaring them. """ use Spark.Dsl.Fragment, of: Ash.Resource, @@ -357,6 +414,11 @@ defmodule Diffo.Provider.BaseInstance do end end + changes do + change Diffo.Provider.Instance.Extension.Changes.BuildBefore, on: [:create] + change Diffo.Provider.Instance.Extension.Changes.BuildAfter, on: [:create] + end + actions do defaults [:destroy] diff --git a/lib/diffo/provider/components/entity_ref.ex b/lib/diffo/provider/components/entity_ref.ex index 532b3c9..8d09ff3 100644 --- a/lib/diffo/provider/components/entity_ref.ex +++ b/lib/diffo/provider/components/entity_ref.ex @@ -4,7 +4,7 @@ defmodule Diffo.Provider.EntityRef do @moduledoc """ - EntityRef - Ash Resource for a TMF Entity Reference + Ash Resource for a TMF Entity Reference """ use Ash.Resource, otp_app: :diffo, diff --git a/lib/diffo/provider/components/instance/extension.ex b/lib/diffo/provider/components/instance/extension.ex index 3956787..222acc1 100644 --- a/lib/diffo/provider/components/instance/extension.ex +++ b/lib/diffo/provider/components/instance/extension.ex @@ -6,13 +6,41 @@ defmodule Diffo.Provider.Instance.Extension do @moduledoc """ DSL Extension customising an Instance. - Provides compile-time declaration blocks for domain-specific Service and Resource kinds - built on `Diffo.Provider.BaseInstance`. All declarations are introspectable via - `Diffo.Provider.Instance.Extension.Info`. + 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. + + ## 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. + See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. """ + + # ── structure ────────────────────────────────────────────────────────────── + @specification %Spark.Dsl.Section{ name: :specification, describe: "Defines the Instance Specification", @@ -31,43 +59,31 @@ defmodule Diffo.Provider.Instance.Extension do schema: [ id: [ type: :string, - doc: """ - The id of the specification, a uuid4 the same in all environments, unique for name and major_version. - """, + 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. - """, + 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. - """, + doc: "The type of the specification.", default: :serviceSpecification ], major_version: [ type: :integer, - doc: """ - The major_version of the specification. - """, + doc: "The major_version of the specification.", default: 1 ], description: [ type: :string, - doc: """ - A generic description of the specified service or resource. - """ + doc: "A generic description of the specified service or resource." ], category: [ type: :string, - doc: """ - The category the specified service or resource belongs to. - """ + doc: "The category the specified service or resource belongs to." ] ] } @@ -79,17 +95,12 @@ defmodule Diffo.Provider.Instance.Extension do args: [:name, :value_type], schema: [ name: [ - doc: """ - The name of the characteristic, an atom - """, + 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. - """, + 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 ] ] @@ -108,9 +119,7 @@ defmodule Diffo.Provider.Instance.Extension do end """ ], - entities: [ - @characteristic - ] + entities: [@characteristic] } @feature %Spark.Dsl.Entity{ @@ -120,16 +129,12 @@ defmodule Diffo.Provider.Instance.Extension do args: [:name], schema: [ name: [ - doc: """ - The name of the feature, an atom - """, + doc: "The name of the feature, an atom", type: :atom, required: true ], is_enabled?: [ - doc: """ - Whether the feature is enabled by default, defaults true - """, + doc: "Whether the feature is enabled by default, defaults true", type: :boolean ] ], @@ -153,9 +158,7 @@ defmodule Diffo.Provider.Instance.Extension do end """ ], - entities: [ - @feature - ] + entities: [@feature] } @party_schema [ @@ -216,12 +219,108 @@ defmodule Diffo.Provider.Instance.Extension do end """ ], - entities: [ - @party_entity, - @parties_entity + entities: [@party_entity, @parties_entity] + } + + @structure %Spark.Dsl.Section{ + name: :structure, + describe: "Defines the structural shape of the Instance — its specification, characteristics, features, and parties", + 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 + end + """ + ], + sections: [@specification, @characteristics, @features, @parties] + } + + # ── 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: [@specification, @features, @characteristics, @parties] + 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.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 + ] end diff --git a/lib/diffo/provider/components/instance/extension/action_create.ex b/lib/diffo/provider/components/instance/extension/action_create.ex new file mode 100644 index 0000000..db5e0c7 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/action_create.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.ActionCreate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/components/instance/extension/action_helper.ex b/lib/diffo/provider/components/instance/extension/action_helper.ex index 8dfa7c1..1d814d4 100644 --- a/lib/diffo/provider/components/instance/extension/action_helper.ex +++ b/lib/diffo/provider/components/instance/extension/action_helper.ex @@ -11,28 +11,17 @@ defmodule Diffo.Provider.Instance.ActionHelper do alias Diffo.Provider.Instance.Place alias Diffo.Provider.Instance.Party - @doc """ - build before_action helper, injects instance dsl configuration into the changeset - """ - def build_before(changeset) do - changeset - |> Specification.set_specified_by_argument() - |> Feature.set_features_argument() - |> Characteristic.set_characteristics_argument() - |> Party.validate_parties() - end + @doc false + def build_after(changeset, result) do + specified_by = Ash.Changeset.get_argument(changeset, :specified_by) + result = %{result | specification_id: specified_by} - @doc """ - build after_action helper, relates TMF entities to the new instance - """ - def build_after(changeset, result, module, function) do - with {:ok, result} <- Specification.relate_instance(result, changeset), - {:ok, result} <- Relationship.relate_instance(result, changeset), - {:ok, result} <- Feature.relate_instance(result, changeset), - {:ok, result} <- Characteristic.relate_instance(result, changeset), - {:ok, result} <- Place.relate_instance(result, changeset), - {:ok, result} <- Party.relate_instance(result, changeset), - {:ok, result} <- apply(module, function, [result.id]), - do: {:ok, result} + with {:ok, _} <- Specification.relate_instance(result, changeset), + {:ok, _} <- Relationship.relate_instance(result, changeset), + {:ok, _} <- Feature.relate_instance(result, changeset), + {:ok, _} <- Characteristic.relate_instance(result, changeset), + {:ok, _} <- Place.relate_instance(result, changeset), + {:ok, _} <- Party.relate_instance(result, changeset), + do: Ash.load(result, [:specification, :characteristics, :features, :parties]) end end diff --git a/lib/diffo/provider/components/instance/extension/action_update.ex b/lib/diffo/provider/components/instance/extension/action_update.ex new file mode 100644 index 0000000..2678c43 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/action_update.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.ActionUpdate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/components/instance/extension/changes/build_after.ex b/lib/diffo/provider/components/instance/extension/changes/build_after.ex new file mode 100644 index 0000000..2f9e586 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/changes/build_after.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Changes.BuildAfter do + @moduledoc false + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, fn changeset, result -> + changeset.resource.build_after(changeset, result) + end) + end +end diff --git a/lib/diffo/provider/components/instance/extension/changes/build_before.ex b/lib/diffo/provider/components/instance/extension/changes/build_before.ex new file mode 100644 index 0000000..0797d81 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/changes/build_before.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Changes.BuildBefore do + @moduledoc false + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.before_action(changeset, fn changeset -> + changeset.resource.build_before(changeset) + end) + end +end diff --git a/lib/diffo/provider/components/instance/extension/characteristic.ex b/lib/diffo/provider/components/instance/extension/characteristic.ex index 43c8d1b..92226d3 100644 --- a/lib/diffo/provider/components/instance/extension/characteristic.ex +++ b/lib/diffo/provider/components/instance/extension/characteristic.ex @@ -8,7 +8,6 @@ defmodule Diffo.Provider.Instance.Characteristic do alias Diffo.Provider alias Diffo.Provider.Instance - alias Diffo.Provider.Instance.Extension.Info alias Diffo.Type.Value @doc """ @@ -19,10 +18,9 @@ defmodule Diffo.Provider.Instance.Characteristic do @doc """ Sets the Extended Instances characteristics argument in the changeset, creating the characteristics """ - def set_characteristics_argument(changeset) when is_struct(changeset, Ash.Changeset) do - %module{} = changeset.data - - case characteristics = create_characteristics(module, :instance) do + 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 @@ -35,13 +33,8 @@ defmodule Diffo.Provider.Instance.Characteristic do end end - @doc """ - Creates the Characteristics from a Extended Instance's module - """ - def create_characteristics(module, type) when is_atom(module) and is_atom(type) do - characteristics = Info.characteristics(module) - - Enum.reduce_while(characteristics, [], fn %{name: name, value_type: value_type}, acc -> + 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 @@ -73,8 +66,7 @@ defmodule Diffo.Provider.Instance.Characteristic do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do characteristics = Ash.Changeset.get_argument(changeset, :characteristics) - instance = struct(Instance, Map.from_struct(result)) - Provider.relate_instance_characteristics(instance, %{characteristics: characteristics}) + Provider.relate_instance_characteristics(%Instance{id: result.id}, %{characteristics: characteristics}) end @doc """ diff --git a/lib/diffo/provider/components/instance/extension/feature.ex b/lib/diffo/provider/components/instance/extension/feature.ex index 9428419..f0c8c75 100644 --- a/lib/diffo/provider/components/instance/extension/feature.ex +++ b/lib/diffo/provider/components/instance/extension/feature.ex @@ -8,7 +8,6 @@ defmodule Diffo.Provider.Instance.Feature do alias Diffo.Provider alias Diffo.Provider.Instance - alias Diffo.Provider.Instance.Extension.Info alias Diffo.Type.Value @doc """ @@ -19,10 +18,9 @@ defmodule Diffo.Provider.Instance.Feature do @doc """ Sets the Extended Instances features argument in the changeset, creating the features and feature characteristics """ - def set_features_argument(changeset) when is_struct(changeset, Ash.Changeset) do - %module{} = changeset.data - - case features = create_features(module) do + 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 @@ -35,14 +33,9 @@ defmodule Diffo.Provider.Instance.Feature do end end - @doc """ - Creates the Features from a Extended Instance's module - """ - def create_features(module) when is_atom(module) do - features = Info.features(module) - + defp create_features_from_declarations(declarations) do Enum.reduce_while( - features, + declarations, [], # create any feature characteristics fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, acc -> @@ -101,8 +94,7 @@ defmodule Diffo.Provider.Instance.Feature do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do features = Ash.Changeset.get_argument(changeset, :features) - instance = struct(Instance, Map.from_struct(result)) - Provider.relate_instance_features(instance, %{features: features}) + Provider.relate_instance_features(%Instance{id: result.id}, %{features: features}) end defimpl String.Chars do diff --git a/lib/diffo/provider/components/instance/extension/info.ex b/lib/diffo/provider/components/instance/extension/info.ex index 27c87d2..3356537 100644 --- a/lib/diffo/provider/components/instance/extension/info.ex +++ b/lib/diffo/provider/components/instance/extension/info.ex @@ -5,5 +5,12 @@ defmodule Diffo.Provider.Instance.Extension.Info do use Spark.InfoGenerator, extension: Diffo.Provider.Instance.Extension, - sections: [:specification, :features, :characteristics, :parties] + sections: [:structure] + + @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 end diff --git a/lib/diffo/provider/components/instance/extension/party.ex b/lib/diffo/provider/components/instance/extension/party.ex index 9505d51..26bb9b4 100644 --- a/lib/diffo/provider/components/instance/extension/party.ex +++ b/lib/diffo/provider/components/instance/extension/party.ex @@ -5,7 +5,6 @@ defmodule Diffo.Provider.Instance.Party do @moduledoc false alias Diffo.Provider - alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo @doc """ Struct for a Party @@ -13,9 +12,7 @@ defmodule Diffo.Provider.Instance.Party do defstruct [:id, :role] @doc false - def validate_parties(changeset) do - declarations = InstanceInfo.parties(changeset.resource) - + def validate_parties(changeset, declarations) do if declarations == [] do changeset else diff --git a/lib/diffo/provider/components/instance/extension/persisters/persist_characteristics.ex b/lib/diffo/provider/components/instance/extension/persisters/persist_characteristics.ex new file mode 100644 index 0000000..e622fcc --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/persisters/persist_characteristics.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.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, [:structure, :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/components/instance/extension/persisters/persist_features.ex b/lib/diffo/provider/components/instance/extension/persisters/persist_features.ex new file mode 100644 index 0000000..53fd427 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/persisters/persist_features.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.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, [:structure, :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/components/instance/extension/persisters/persist_parties.ex b/lib/diffo/provider/components/instance/extension/persisters/persist_parties.ex new file mode 100644 index 0000000..ff25478 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/persisters/persist_parties.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Persisters.PersistParties do + @moduledoc "Persists party declarations 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, [:structure, :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/components/instance/extension/persisters/persist_specification.ex b/lib/diffo/provider/components/instance/extension/persisters/persist_specification.ex new file mode 100644 index 0000000..4cfd5f9 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/persisters/persist_specification.ex @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.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, [:structure, :specification], :id), + name: Transformer.get_option(dsl_state, [:structure, :specification], :name), + type: Transformer.get_option(dsl_state, [:structure, :specification], :type, :serviceSpecification), + major_version: Transformer.get_option(dsl_state, [:structure, :specification], :major_version, 1), + description: Transformer.get_option(dsl_state, [:structure, :specification], :description), + category: Transformer.get_option(dsl_state, [:structure, :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/components/instance/extension/specification.ex b/lib/diffo/provider/components/instance/extension/specification.ex index 8f5720a..b40ed56 100644 --- a/lib/diffo/provider/components/instance/extension/specification.ex +++ b/lib/diffo/provider/components/instance/extension/specification.ex @@ -8,7 +8,6 @@ defmodule Diffo.Provider.Instance.Specification do alias Diffo.Provider alias Diffo.Provider.Instance - alias Diffo.Provider.Instance.Extension.Info @doc """ Struct for a Specification @@ -18,31 +17,16 @@ defmodule Diffo.Provider.Instance.Specification do @doc """ Sets the specified_by argument in the changeset, ensuring the Extended Instance's specification exists """ - def set_specified_by_argument(changeset) when is_struct(changeset, Ash.Changeset) do - %module{} = changeset.data - # ensure the specification exists - case upsert_specification(module) do - {:ok, specification} -> - Ash.Changeset.force_set_argument(changeset, :specified_by, specification.id) - - {:error, error} -> - Ash.Changeset.add_error(changeset, error) - end - end - - @doc """ - Upserts the Specification from a Extended Instance's module - """ - def upsert_specification(module) when is_atom(module) do - options = Info.specification_options(module) + def set_specified_by_argument(changeset, options) + when is_struct(changeset, Ash.Changeset) and is_list(options) do specification = struct(__MODULE__, options) case Provider.create_specification(Map.from_struct(specification)) do - {:ok, _result} -> - {:ok, specification} + {:ok, _} -> + Ash.Changeset.force_set_argument(changeset, :specified_by, specification.id) {:error, error} -> - {:error, error} + Ash.Changeset.add_error(changeset, error) end end @@ -52,18 +36,7 @@ defmodule Diffo.Provider.Instance.Specification do def relate_instance(result, changeset) when is_struct(result) and is_struct(changeset, Ash.Changeset) do specified_by = Ash.Changeset.get_argument(changeset, :specified_by) - instance = struct(Instance, Map.from_struct(result)) - - case Provider.specify_instance(instance, %{specified_by: specified_by}) do - {:ok, specification} -> - {:ok, - result - |> Map.put(:specification, specification) - |> Map.put(:specification_id, specified_by)} - - {:error, error} -> - {:error, error} - end + Provider.specify_instance(%Instance{id: result.id}, %{specified_by: specified_by}) end defimpl String.Chars do diff --git a/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex new file mode 100644 index 0000000..5aea342 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.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.Instance.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.Instance.Feature.set_features_argument(features()) + |> Diffo.Provider.Instance.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)) + end)} + end + + defp inject_create_arguments(dsl_state) do + action_create_declarations = + Transformer.get_entities(dsl_state, [: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.Instance.Extension.Persisters.PersistSpecification), do: true + def after?(Diffo.Provider.Instance.Extension.Persisters.PersistCharacteristics), do: true + def after?(Diffo.Provider.Instance.Extension.Persisters.PersistFeatures), do: true + def after?(Diffo.Provider.Instance.Extension.Persisters.PersistParties), do: true + def after?(_), do: false +end diff --git a/lib/diffo/provider/components/instance/extension/verifiers/verify_behaviour.ex b/lib/diffo/provider/components/instance/extension/verifiers/verify_behaviour.ex new file mode 100644 index 0000000..7cc10db --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/verifiers/verify_behaviour.ex @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.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.Instance.Extension.ActionCreate + alias Diffo.Provider.Instance.Extension.ActionUpdate + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + behaviour_actions = Verifier.get_entities(dsl_state, [: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: [: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: [: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/components/instance/extension/verifiers/verify_characteristics.ex b/lib/diffo/provider/components/instance/extension/verifiers/verify_characteristics.ex new file mode 100644 index 0000000..b81e133 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/verifiers/verify_characteristics.ex @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Verifiers.VerifyCharacteristics do + @moduledoc "Verifies that 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, [:structure, :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: [:structure, :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: [:structure, :characteristics, char.name], + message: "characteristics: value_type #{inspect(module)} does not exist" + ) + | acc + ] + end + + :error -> + acc + end + end) + + errors = duplicate_errors ++ type_errors + + case 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/components/instance/extension/verifiers/verify_features.ex b/lib/diffo/provider/components/instance/extension/verifiers/verify_features.ex new file mode 100644 index 0000000..d85e65f --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/verifiers/verify_features.ex @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Verifiers.VerifyFeatures do + @moduledoc "Verifies that feature names are unique, feature 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) + features = Verifier.get_entities(dsl_state, [:structure, :features]) + + duplicate_feature_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: [:structure, :features], + message: "features: name #{inspect(name)} is declared more than once" + ) + end) + + type_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: [:structure, :features, feature.name, :characteristics], + message: "features: characteristic name #{inspect(name)} is declared more than once in #{inspect(feature.name)}" + ) + end) + + Enum.reduce(feature.characteristics, acc ++ duplicate_char_errors, 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: [:structure, :features, feature.name, :characteristics, char.name], + message: "features: characteristic value_type #{inspect(module)} does not exist" + ) + | inner_acc + ] + end + + :error -> + inner_acc + end + end) + end) + + errors = duplicate_feature_errors ++ type_errors + + case 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/components/instance/extension/verifiers/verify_parties.ex b/lib/diffo/provider/components/instance/extension/verifiers/verify_parties.ex new file mode 100644 index 0000000..63bd593 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/verifiers/verify_parties.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Verifiers.VerifyParties do + @moduledoc "Verifies party role declarations — no duplicates, party_type modules must exist" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Party.Extension.Info, as: PartyInfo + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + parties = Verifier.get_entities(dsl_state, [:structure, :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: [:structure, :parties], + message: "parties: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(parties, [], fn party, acc -> + cond do + is_nil(party.party_type) -> + acc + + !Code.ensure_loaded?(party.party_type) -> + [ + DslError.exception( + module: resource, + path: [:structure, :parties, party.role], + message: "parties: party_type #{inspect(party.party_type)} does not exist" + ) + | acc + ] + + !PartyInfo.party?(party.party_type) -> + [ + DslError.exception( + module: resource, + path: [:structure, :parties, party.role], + message: "parties: party_type #{inspect(party.party_type)} does not extend BaseParty" + ) + | acc + ] + + true -> + acc + end + end) + + errors = duplicate_errors ++ type_errors + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/components/instance/extension/verifiers/verify_specification.ex b/lib/diffo/provider/components/instance/extension/verifiers/verify_specification.ex new file mode 100644 index 0000000..2368126 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/verifiers/verify_specification.ex @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Verifiers.VerifySpecification do + @moduledoc "Verifies that the specification id is a valid UUID4" + 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) + spec_id = Verifier.get_option(dsl_state, [:structure, :specification], :id) + + errors = + if spec_id && !Diffo.Uuid.uuid4?(spec_id) do + [ + DslError.exception( + module: resource, + path: [:structure, :specification, :id], + message: "specification: id must be a valid UUID4" + ) + ] + else + [] + end + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/components/instance/info.ex b/lib/diffo/provider/components/instance/info.ex new file mode 100644 index 0000000..ae17ac9 --- /dev/null +++ b/lib/diffo/provider/components/instance/info.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Info do + @moduledoc "Public introspection API for resources extending Diffo.Provider.BaseInstance" + + alias Spark.Dsl.Extension + + @doc "Returns the normalised specification keyword list for the resource" + @spec specification(Ash.Resource.t()) :: keyword() | nil + def specification(resource) do + Extension.get_persisted(resource, :specification) + end + + @doc "Returns the list of characteristic declarations for the resource" + @spec characteristics(Ash.Resource.t()) :: list() | [] + def characteristics(resource) do + Extension.get_persisted(resource, :characteristics, []) + end + + @doc "Returns the list of feature declarations for the resource" + @spec features(Ash.Resource.t()) :: list() | [] + def features(resource) do + Extension.get_persisted(resource, :features, []) + end + + @doc "Returns the list of party role declarations for the resource" + @spec parties(Ash.Resource.t()) :: list() | [] + def parties(resource) do + Extension.get_persisted(resource, :parties, []) + end + + @doc "Returns the named characteristic declaration, or nil" + @spec characteristic(Ash.Resource.t(), atom()) :: struct() | nil + def characteristic(resource, name) do + Enum.find(characteristics(resource), &(&1.name == name)) + end + + @doc "Returns the named feature declaration, or nil" + @spec feature(Ash.Resource.t(), atom()) :: struct() | nil + def feature(resource, name) do + Enum.find(features(resource), &(&1.name == name)) + end + + @doc "Returns the named characteristic within a feature, or nil" + @spec feature_characteristic(Ash.Resource.t(), atom(), atom()) :: struct() | nil + def feature_characteristic(resource, feature_name, char_name) do + case feature(resource, feature_name) do + nil -> nil + f -> Enum.find(f.characteristics, &(&1.name == char_name)) + end + end + + @doc "Returns the party declaration for the given role, or nil" + @spec party(Ash.Resource.t(), atom()) :: struct() | nil + def party(resource, role) do + Enum.find(parties(resource), &(&1.role == role)) + end +end diff --git a/lib/diffo/provider/components/party/extension/info.ex b/lib/diffo/provider/components/party/extension/info.ex index 5638989..8c29009 100644 --- a/lib/diffo/provider/components/party/extension/info.ex +++ b/lib/diffo/provider/components/party/extension/info.ex @@ -6,4 +6,11 @@ defmodule Diffo.Provider.Party.Extension.Info do use Spark.InfoGenerator, extension: Diffo.Provider.Party.Extension, sections: [:instances, :parties] + + @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 end diff --git a/test/instance_extension/party_test.exs b/test/instance_extension/party_test.exs index 35deb16..04c1a71 100644 --- a/test/instance_extension/party_test.exs +++ b/test/instance_extension/party_test.exs @@ -53,14 +53,14 @@ defmodule Diffo.InstanceExtension.PartyTest do describe "Instance DSL — Shelf parties" do test "party declarations are accessible via info" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) roles = Enum.map(parties, & &1.role) assert :facilitator in roles assert :overseer in roles end test "party types are correct" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) facilitator = Enum.find(parties, &(&1.role == :facilitator)) overseer = Enum.find(parties, &(&1.role == :overseer)) assert facilitator.party_type == Organization @@ -68,38 +68,38 @@ defmodule Diffo.InstanceExtension.PartyTest do end test "singular party defaults to multiple: false" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.multiple == false end test "reference: true is declared" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) provider = Enum.find(parties, &(&1.role == :provider)) assert provider.reference == true assert provider.multiple == false end test "reference defaults to false" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.reference == false end test "calculate: is declared" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) manager = Enum.find(parties, &(&1.role == :manager)) assert manager.calculate == :manager_calc end test "parties (plural) sets multiple: true" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.multiple == true end test "parties (plural) constraints are declared" do - parties = InstanceInfo.parties(Shelf) + parties = InstanceInfo.structure_parties(Shelf) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.constraints == [min: 1, max: 3] end diff --git a/test/instance_extension/transformer_test.exs b/test/instance_extension/transformer_test.exs new file mode 100644 index 0000000..2d9363a --- /dev/null +++ b/test/instance_extension/transformer_test.exs @@ -0,0 +1,245 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.InstanceExtension.TransformerTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Test.Shelf + alias Diffo.Test.Card + alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Instance.Feature + alias Diffo.Provider.Instance.Info + + describe "PersistSpecification" do + test "bakes specification/0 onto the resource" do + spec = Shelf.specification() + assert spec[:id] == "ef016d85-9dbd-429c-84da-1df56cc7dda5" + assert spec[:name] == "shelf" + assert spec[:type] == :resourceSpecification + assert spec[:description] == "A Shelf Resource Instance which contain cards" + assert spec[:category] == "Network Resource" + assert spec[:major_version] == 1 + end + + test "card specification is baked correctly" do + spec = Card.specification() + assert spec[:id] == "cd29956f-6c68-44cc-bf54-705eb8d2f754" + assert spec[:name] == "card" + assert spec[:type] == :resourceSpecification + end + + test "specification is also accessible via Info" do + assert Info.specification(Shelf)[:name] == "shelf" + assert Info.specification(Card)[:name] == "card" + end + end + + describe "PersistCharacteristics" do + test "bakes characteristics/0 onto the resource" do + chars = Shelf.characteristics() + assert is_list(chars) + assert length(chars) == 3 + names = Enum.map(chars, & &1.name) + assert :shelf in names + assert :slots in names + assert :shelves in names + end + + test "each characteristic is a Characteristic struct" do + [first | _] = Shelf.characteristics() + assert is_struct(first, Characteristic) + end + + test "characteristics are also accessible via Info" do + assert length(Info.characteristics(Shelf)) == 3 + assert length(Info.characteristics(Card)) == 2 + end + + test "Info.characteristic/2 returns the named characteristic" do + char = Info.characteristic(Shelf, :shelves) + assert char.name == :shelves + end + + test "Info.characteristic/2 returns nil for unknown name" do + assert Info.characteristic(Shelf, :nonexistent) == nil + end + end + + describe "PersistFeatures" do + test "bakes features/0 onto the resource" do + features = Shelf.features() + assert is_list(features) + assert length(features) == 1 + [feature] = features + assert feature.name == :spectralManagement + assert feature.is_enabled? == true + end + + test "each feature is a Feature struct" do + [feature] = Shelf.features() + assert is_struct(feature, Feature) + end + + test "feature characteristics are nested in the declaration" do + [feature] = Shelf.features() + assert length(feature.characteristics) == 2 + char_names = Enum.map(feature.characteristics, & &1.name) + assert :deploymentClass in char_names + assert :deploymentClasses in char_names + end + + test "features are also accessible via Info" do + assert length(Info.features(Shelf)) == 1 + assert Info.features(Card) == [] + end + + test "Info.feature/2 returns the named feature" do + feature = Info.feature(Shelf, :spectralManagement) + assert feature.name == :spectralManagement + end + + test "Info.feature/2 returns nil for unknown name" do + assert Info.feature(Shelf, :nonexistent) == nil + end + + test "Info.feature_characteristic/3 returns the named characteristic within a feature" do + char = Info.feature_characteristic(Shelf, :spectralManagement, :deploymentClass) + assert char.name == :deploymentClass + end + + test "Info.feature_characteristic/3 returns nil for unknown feature" do + assert Info.feature_characteristic(Shelf, :nonexistent, :deploymentClass) == nil + end + + test "Info.feature_characteristic/3 returns nil for unknown characteristic" do + assert Info.feature_characteristic(Shelf, :spectralManagement, :nonexistent) == nil + end + end + + describe "PersistParties" do + test "bakes parties/0 onto the resource" do + parties = Shelf.parties() + assert is_list(parties) + assert length(parties) == 5 + roles = Enum.map(parties, & &1.role) + assert :facilitator in roles + assert :overseer in roles + assert :provider in roles + assert :manager in roles + assert :installer in roles + end + + test "reference party has reference flag set" do + provider = Enum.find(Shelf.parties(), &(&1.role == :provider)) + assert provider.reference == true + end + + test "calculate party has calculate set" do + manager = Enum.find(Shelf.parties(), &(&1.role == :manager)) + assert manager.calculate == :manager_calc + end + + test "plural party has constraints" do + installer = Enum.find(Shelf.parties(), &(&1.role == :installer)) + assert installer.multiple == true + assert installer.constraints == [min: 1, max: 3] + end + + test "parties are also accessible via Info" do + assert length(Info.parties(Shelf)) == 5 + assert Info.parties(Card) == [] + end + + test "Info.party/2 returns the named party declaration by role" do + p = Info.party(Shelf, :facilitator) + assert p.role == :facilitator + end + + test "Info.party/2 returns nil for unknown role" do + assert Info.party(Shelf, :nonexistent) == nil + end + end + + describe "TransformBehaviour" do + setup do + Code.ensure_loaded!(Shelf) + Code.ensure_loaded!(Card) + :ok + end + + test "build_before/1 is defined on shelf" do + assert function_exported?(Shelf, :build_before, 1) + end + + test "build_after/2 is defined on shelf" do + assert function_exported?(Shelf, :build_after, 2) + end + + test "build_before/1 is defined on card" do + assert function_exported?(Card, :build_before, 1) + end + + test "build_after/2 is defined on card" do + assert function_exported?(Card, :build_after, 2) + end + + test "action_create injects :specified_by argument into :build" do + action = Ash.Resource.Info.action(Shelf, :build) + arg_names = Enum.map(action.arguments, & &1.name) + assert :specified_by in arg_names + assert :features in arg_names + assert :characteristics in arg_names + end + + test "injected arguments are not public" do + action = Ash.Resource.Info.action(Shelf, :build) + injected = Enum.filter(action.arguments, &(&1.name in [:specified_by, :features, :characteristics])) + assert Enum.all?(injected, &(&1.public? == false)) + end + + test "characteristic/1 returns the named characteristic" do + char = Shelf.characteristic(:shelves) + assert char.name == :shelves + assert char.value_type == {:array, Diffo.Test.ShelfValue} + end + + test "characteristic/1 returns nil for unknown name" do + assert Shelf.characteristic(:nonexistent) == nil + end + + test "feature/1 returns the named feature" do + feature = Shelf.feature(:spectralManagement) + assert feature.name == :spectralManagement + assert feature.is_enabled? == true + end + + test "feature/1 returns nil for unknown name" do + assert Shelf.feature(:nonexistent) == nil + end + + test "feature_characteristic/2 returns the named characteristic within a feature" do + char = Shelf.feature_characteristic(:spectralManagement, :deploymentClass) + assert char.name == :deploymentClass + end + + test "feature_characteristic/2 returns nil for unknown feature" do + assert Shelf.feature_characteristic(:nonexistent, :deploymentClass) == nil + end + + test "feature_characteristic/2 returns nil for unknown characteristic" do + assert Shelf.feature_characteristic(:spectralManagement, :nonexistent) == nil + end + + test "party/1 returns the named party declaration by role" do + p = Shelf.party(:facilitator) + assert p.role == :facilitator + assert p.multiple == false + end + + test "party/1 returns nil for unknown role" do + assert Shelf.party(:nonexistent) == nil + end + end +end diff --git a/test/instance_extension/verifier_test.exs b/test/instance_extension/verifier_test.exs new file mode 100644 index 0000000..d679b70 --- /dev/null +++ b/test/instance_extension/verifier_test.exs @@ -0,0 +1,367 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.InstanceExtension.VerifierTest do + @moduledoc false + use ExUnit.Case, async: false + alias Diffo.Test.Util + + describe "specification verifier" do + test "invalid UUID4 in specification id warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "specification: id must be a valid UUID4", + fn -> + defmodule InvalidSpecId do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with invalid spec id" + end + + structure do + specification do + id "ef016d85-9dbd-429c-04da-1df56cc7dda5" + name "invalid" + end + end + end + end + ) + end + end + + describe "characteristics verifier" do + test "duplicate characteristic name warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "characteristics: name :foo is declared more than once", + fn -> + defmodule DuplicateCharName do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with duplicate characteristic name" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + characteristics do + characteristic :foo, Diffo.Test.ShelfValue + characteristic :foo, Diffo.Test.ShelfValue + end + end + end + end + ) + end + + test "non-existent value_type module warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "characteristics: value_type NonExistent.CharValue does not exist", + fn -> + defmodule InvalidCharValueType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with non-existent characteristic value_type" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + characteristics do + characteristic :foo, NonExistent.CharValue + end + end + end + end + ) + end + + test "non-existent array value_type module warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "characteristics: value_type NonExistent.ArrayValue does not exist", + fn -> + defmodule InvalidArrayCharValueType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with non-existent array characteristic value_type" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + characteristics do + characteristic :bar, {:array, NonExistent.ArrayValue} + end + end + end + end + ) + end + end + + describe "features verifier" do + test "duplicate feature name warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "features: name :my_feature is declared more than once", + fn -> + defmodule DuplicateFeatureName do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with duplicate feature names" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + features do + feature :my_feature do + end + + feature :my_feature do + end + end + end + end + end + ) + end + + test "duplicate feature characteristic name warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "features: characteristic name :baz is declared more than once in :my_feature", + fn -> + defmodule DuplicateFeatureCharName do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with duplicate feature characteristic names" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + features do + feature :my_feature do + characteristic :baz, Diffo.Test.ShelfValue + characteristic :baz, Diffo.Test.ShelfValue + end + end + end + end + end + ) + end + + test "non-existent feature characteristic value_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "features: characteristic value_type NonExistent.FeatureValue does not exist", + fn -> + defmodule InvalidFeatureCharValueType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with non-existent feature characteristic value_type" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + features do + feature :my_feature do + characteristic :baz, NonExistent.FeatureValue + end + end + end + end + end + ) + end + end + + describe "parties verifier" do + test "duplicate party role names warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: role :operator is declared more than once", + fn -> + defmodule DuplicatePartyRole do + alias Diffo.Provider.BaseInstance + alias Diffo.Test.Shelf + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with duplicate party roles" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party :operator, Shelf + party :operator, Shelf + end + end + end + end + ) + end + + test "non-existent party_type module warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type NonExistent.PartyModule does not exist", + fn -> + defmodule InvalidPartyType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with non-existent party_type" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party :operator, NonExistent.PartyModule + end + end + end + end + ) + end + + test "party_type not extending BaseParty 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 InvalidPartyBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with party_type that is not a BaseParty" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party :operator, Diffo.Test.Shelf + end + end + end + end + ) + end + end + + describe "behaviour verifier" do + test "undeclared create action name warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "behaviour: create :nonexistent does not exist as a create action on this resource", + fn -> + defmodule BehaviourMissingCreate do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with behaviour referencing a missing create action" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + end + + behaviour do + actions do + create :nonexistent + end + end + end + end + ) + end + + test "undeclared update action name warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "behaviour: update :nonexistent does not exist as an update action on this resource", + fn -> + defmodule BehaviourMissingUpdate do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with behaviour referencing a missing update action" + end + + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + end + + behaviour do + actions do + update :nonexistent + end + end + end + end + ) + end + end +end diff --git a/test/support/resource/card.ex b/test/support/resource/card.ex index 4056a1b..9792b8e 100644 --- a/test/support/resource/card.ex +++ b/test/support/resource/card.ex @@ -11,7 +11,6 @@ defmodule Diffo.Test.Card do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.ActionHelper alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -27,40 +26,36 @@ defmodule Diffo.Test.Card do plural_name :Cards end - specification do - id "cd29956f-6c68-44cc-bf54-705eb8d2f754" - name "card" - type :resourceSpecification - description "A Card Resource Instance" - category "Network Resource" + structure do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "card" + type :resourceSpecification + description "A Card Resource Instance" + category "Network Resource" + end + + characteristics do + characteristic :card, CardValue + characteristic :ports, AssignableValue + end end - characteristics do - characteristic :card, CardValue - characteristic :ports, AssignableValue + behaviour do + actions do + create :build + end end actions do create :build do description "creates a new Card resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false argument :places, {:array, :struct} argument :parties, {:array, :struct} change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> - ActionHelper.build_before(changeset) - end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after(changeset, result, Servo, :get_card_by_id) - end) - change load [:href] upsert? false end diff --git a/test/support/resource/invalid/invalid_characteristic.ex b/test/support/resource/invalid/invalid_characteristic.ex index 52b0ef6..2053dba 100644 --- a/test/support/resource/invalid/invalid_characteristic.ex +++ b/test/support/resource/invalid/invalid_characteristic.ex @@ -10,55 +10,33 @@ defmodule Diffo.Test.InvalidCharacteristic do """ alias Diffo.Provider.BaseInstance - alias Diffo.Provider.Instance.ActionHelper - - alias Diffo.Test.Servo use Ash.Resource, fragments: [BaseInstance], - domain: Servo + domain: Diffo.Test.Servo resource do description "Ash Resource with an invalid characteristic" end - specification do - id "3caf29b9-0b91-4b8f-8568-2960131b1feb" - name "invalidCharacteristic" - type :resourceSpecification - category "Network Resource" - end + structure do + specification do + id "3caf29b9-0b91-4b8f-8568-2960131b1feb" + name "invalidCharacteristic" + type :resourceSpecification + category "Network Resource" + end - characteristics do - characteristic :invalid, InvalidValue + characteristics do + characteristic :invalid, InvalidValue + end end actions do create :build do description "creates a new InvalidCharacteristic resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false - argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false - argument :places, {:array, :struct} - argument :parties, {:array, :struct} - change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> - ActionHelper.build_before(changeset) - end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after( - changeset, - result, - Servo, - :get_invalid_characteristic_by_id - ) - end) - change load [:href] upsert? false end diff --git a/test/support/resource/invalid/invalid_feature_characteristic.ex b/test/support/resource/invalid/invalid_feature_characteristic.ex index 2d8f791..143b717 100644 --- a/test/support/resource/invalid/invalid_feature_characteristic.ex +++ b/test/support/resource/invalid/invalid_feature_characteristic.ex @@ -10,29 +10,28 @@ defmodule Diffo.Test.InvalidFeatureCharacteristic do """ alias Diffo.Provider.BaseInstance - alias Diffo.Provider.Instance.ActionHelper - - alias Diffo.Test.Servo use Ash.Resource, fragments: [BaseInstance], - domain: Servo + domain: Diffo.Test.Servo resource do description "Ash Resource with an invalid feature characteristic" end - specification do - id "1f2402ca-82da-428e-a58b-5405a5431386" - name "invalidFeatureCharacteristic" - type :resourceSpecification - category "Network Resource" - end + structure do + specification do + id "1f2402ca-82da-428e-a58b-5405a5431386" + name "invalidFeatureCharacteristic" + type :resourceSpecification + category "Network Resource" + end - features do - feature :invalid_feature_characteristic do - is_enabled? true - characteristic :invalid, InvalidValue + features do + feature :invalid_feature_characteristic do + is_enabled? true + characteristic :invalid, InvalidValue + end end end @@ -40,28 +39,7 @@ defmodule Diffo.Test.InvalidFeatureCharacteristic do create :build do description "creates a new InvalidFeatureCharacteristic resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false - argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false - argument :places, {:array, :struct} - argument :parties, {:array, :struct} - change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> - ActionHelper.build_before(changeset) - end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after( - changeset, - result, - Servo, - :get_invalid_feature_characteristic_by_id - ) - end) - change load [:href] upsert? false end diff --git a/test/support/resource/invalid/invalid_specification.ex b/test/support/resource/invalid/invalid_specification.ex index 4591912..b409619 100644 --- a/test/support/resource/invalid/invalid_specification.ex +++ b/test/support/resource/invalid/invalid_specification.ex @@ -10,51 +10,29 @@ defmodule Diffo.Test.InvalidSpecification do """ alias Diffo.Provider.BaseInstance - alias Diffo.Provider.Instance.ActionHelper - - alias Diffo.Test.Servo use Ash.Resource, fragments: [BaseInstance], - domain: Servo + domain: Diffo.Test.Servo resource do description "Ash Resource with an invalid specification" end - specification do - id "ef016d85-9dbd-429c-04da-1df56cc7dda5" - name "invalidSpecification" - type :resourceSpecification - category "Network Resource" + structure do + specification do + id "ef016d85-9dbd-429c-04da-1df56cc7dda5" + name "invalidSpecification" + type :resourceSpecification + category "Network Resource" + end end actions do create :build do description "creates a new InvalidSpecification resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false - argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false - argument :places, {:array, :struct} - argument :parties, {:array, :struct} - change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> - ActionHelper.build_before(changeset) - end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after( - changeset, - result, - Servo, - :get_invalid_specification_by_id - ) - end) - change load [:href] upsert? false end diff --git a/test/support/resource/shelf.ex b/test/support/resource/shelf.ex index 71350ce..b9022b3 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/shelf.ex @@ -12,7 +12,6 @@ defmodule Diffo.Test.Shelf do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.ActionHelper alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -30,57 +29,53 @@ defmodule Diffo.Test.Shelf do plural_name :Shelves end - specification do - id "ef016d85-9dbd-429c-84da-1df56cc7dda5" - name "shelf" - type :resourceSpecification - description "A Shelf Resource Instance which contain cards" - category "Network Resource" - end + structure do + specification do + id "ef016d85-9dbd-429c-84da-1df56cc7dda5" + name "shelf" + type :resourceSpecification + description "A Shelf Resource Instance which contain cards" + category "Network Resource" + end - features do - feature :spectralManagement do - is_enabled? true - characteristic :deploymentClass, DeploymentClassValue - characteristic :deploymentClasses, {:array, DeploymentClassValue} + features do + feature :spectralManagement do + is_enabled? true + characteristic :deploymentClass, DeploymentClassValue + characteristic :deploymentClasses, {:array, DeploymentClassValue} + end end - end - characteristics do - characteristic :shelf, ShelfValue - characteristic :slots, AssignableValue - characteristic :shelves, {:array, ShelfValue} + characteristics do + characteristic :shelf, ShelfValue + characteristic :slots, AssignableValue + characteristic :shelves, {:array, ShelfValue} + end + + parties do + party :facilitator, Diffo.Test.Organization + party :overseer, Diffo.Test.Person + party :provider, Diffo.Test.Organization, reference: true + party :manager, Diffo.Test.Organization, calculate: :manager_calc + parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] + end end - parties do - party :facilitator, Diffo.Test.Organization - party :overseer, Diffo.Test.Person - party :provider, Diffo.Test.Organization, reference: true - party :manager, Diffo.Test.Organization, calculate: :manager_calc - parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] + behaviour do + actions do + create :build + end end actions do create :build do description "creates a new Shelf resource instance for build" accept [:id, :name, :type, :which] - argument :specified_by, :uuid, public?: false argument :relationships, {:array, :struct} - argument :features, {:array, :uuid}, public?: false - argument :characteristics, {:array, :uuid}, public?: false argument :places, {:array, :struct} argument :parties, {:array, :struct} change set_attribute(:type, :resource) - - change before_action(fn changeset, _context -> - ActionHelper.build_before(changeset) - end) - - change after_action(fn changeset, result, _context -> - ActionHelper.build_after(changeset, result, Servo, :get_shelf_by_id) - end) - change load [:href] upsert? false end diff --git a/test/support/util.ex b/test/support/util.ex new file mode 100644 index 0000000..0b01056 --- /dev/null +++ b/test/support/util.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Util do + @moduledoc false + use ExUnit.Case + import ExUnit.CaptureIO + + def assert_compile_time_warning(module, message, fun) when is_bitstring(message) do + output = capture_io(:stderr, fun) + assert output =~ String.trim_leading("#{module}", "Elixir.") + assert output =~ message + end +end