From bb4c60fd30150da288405cc8a427d213aeb5a938 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 26 Apr 2026 19:49:54 +0930 Subject: [PATCH] base party and related DSL and livebook --- .vscode/launch.json | 34 -- .../DSL-Diffo.Provider.Instance.Extension.md | 50 +++ .../DSL-Diffo.Provider.Party.Extension.md | 108 +++++ ...-Diffo.Provider.Party.Extension.md.license | 3 + ...md => use_diffo_provider_extension.livemd} | 386 +++++++++++++----- lib/diffo/provider/components/base_party.ex | 136 ++++++ .../provider/components/instance/extension.ex | 40 +- .../components/instance/extension/info.ex | 2 +- .../instance/extension/party_declaration.ex | 17 + .../provider/components/party/extension.ex | 74 ++++ .../components/party/extension/info.ex | 9 + .../party/extension/instance_role.ex | 17 + .../components/party/extension/party_role.ex | 17 + lib/diffo/repo.ex | 6 +- mix.exs | 5 +- test/instance_extension/party_test.exs | 90 ++++ test/support/nbn.ex | 35 ++ test/support/resource/organisation.ex | 27 ++ test/support/resource/person.ex | 27 ++ test/support/resource/shelf.ex | 5 + 20 files changed, 936 insertions(+), 152 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 documentation/dsls/DSL-Diffo.Provider.Party.Extension.md create mode 100644 documentation/dsls/DSL-Diffo.Provider.Party.Extension.md.license rename documentation/how_to/{use_diffo_provider_instance_extension.livemd => use_diffo_provider_extension.livemd} (76%) create mode 100644 lib/diffo/provider/components/base_party.ex create mode 100644 lib/diffo/provider/components/instance/extension/party_declaration.ex create mode 100644 lib/diffo/provider/components/party/extension.ex create mode 100644 lib/diffo/provider/components/party/extension/info.ex create mode 100644 lib/diffo/provider/components/party/extension/instance_role.ex create mode 100644 lib/diffo/provider/components/party/extension/party_role.ex create mode 100644 test/instance_extension/party_test.exs create mode 100644 test/support/nbn.ex create mode 100644 test/support/resource/organisation.ex create mode 100644 test/support/resource/person.ex diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 4a9df11..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2025 diffo contributors -// -// SPDX-License-Identifier: MIT -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - - { - "type": "mix_task", - "name": "mix (Default task)", - "request": "launch", - "projectDir": "${workspaceRoot}" - }, - { - "type": "mix_task", - "name": "mix test", - "request": "launch", - "task": "test", - "taskArgs": [ - "--trace" - ], - "startApps": true, - "projectDir": "${workspaceRoot}", - "requireFiles": [ - "test/**/test_helper.exs", - "test/**/*_test.exs" - ] - } - ] -} \ No newline at end of file diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md index b1685af..4c19110 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md @@ -183,5 +183,55 @@ Target: `Diffo.Provider.Instance.Characteristic` +## parties +List of Instance Party roles + +### Nested DSLs + * [party](#parties-party) + + +### Examples +``` +parties do + party :facilitated_by, MyApp.Rsp + party :overseen_by, MyApp.Person +end + +``` + + + + +### parties.party +```elixir +party role, party_type +``` + + +Declares a party role on this Instance + + + + + +### Arguments + +| 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. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Instance.Extension.PartyDeclaration` + + + + diff --git a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md new file mode 100644 index 0000000..eb8d110 --- /dev/null +++ b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md @@ -0,0 +1,108 @@ + +# Diffo.Provider.Party.Extension + +DSL Extension customising a Party + + +## instance +Declares the roles this Party kind plays with respect to Instances + +### Nested DSLs + * [role](#instance-role) + + +### Examples +``` +instance do + role :facilitates, MyApp.AccessService +end + +``` + + + + +### instance.role +```elixir +role role, party_type +``` + + +Declares a role this Party kind plays + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#instance-role-role){: #instance-role-role .spark-required} | `atom` | | The role name, an atom | +| [`party_type`](#instance-role-party_type){: #instance-role-party_type } | `any` | | The module of the related resource | + + + + + + +### Introspection + +Target: `Diffo.Provider.Party.Extension.InstanceRole` + + + + +## party +Declares the roles this Party kind plays with respect to other Parties + +### Nested DSLs + * [role](#party-role) + + +### Examples +``` +party do + role :managed_by, MyApp.Person +end + +``` + + + + +### party.role +```elixir +role role, party_type +``` + + +Declares a role this Party kind plays with respect to other Parties + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#party-role-role){: #party-role-role .spark-required} | `atom` | | The role name, an atom | +| [`party_type`](#party-role-party_type){: #party-role-party_type } | `any` | | The module of the related Party kind | + + + + + + +### Introspection + +Target: `Diffo.Provider.Party.Extension.PartyRole` + + + + + + diff --git a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md.license b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md.license new file mode 100644 index 0000000..b381ebe --- /dev/null +++ b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 diffo contributors + +SPDX-License-Identifier: MIT diff --git a/documentation/how_to/use_diffo_provider_instance_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd similarity index 76% rename from documentation/how_to/use_diffo_provider_instance_extension.livemd rename to documentation/how_to/use_diffo_provider_extension.livemd index 45348f3..d6cfc03 100644 --- a/documentation/how_to/use_diffo_provider_instance_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -4,12 +4,15 @@ SPDX-FileCopyrightText: 2025 diffo contributors -# Using the Diffo Provider Instance Extension +# Using the Diffo Provider Extension ```elixir Mix.install( [ - {:diffo, "~> 0.2.0"} + {:diffo, "~> 0.2.0"}, + ], + config: [ + diffo: [ash_domains: [Diffo.Provider]] ], consolidate_protocols: false ) @@ -19,18 +22,19 @@ Mix.install( Diffo is a Telecommunications Management Forum (TMF) Service and Resource Manager, built for autonomous networks. -It is implemented using the [Ash Framework](https://www.ash-hq.org) leveraging core and community extensions including some created and maintained by [diffo-dev](https://github.com/diffo-dev/). As such it is highly customizable using Spark DSL and as necessary Elixir. +It is implemented using the [Ash Framework](https://www.ash-hq.org) leveraging core and community extensions including some created and maintained by [diffo-dev](https://github.com/diffo-dev/). As such it is highly customizable using Spark DSL and as necessary Elixir. If you are not already familiar with Ash then please explore [Ash Get Started](https://hexdocs.pm/ash/get-started.html) First ensure you've explored the Diffo Livebook for an introduction to Diffo: [![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo%2Ddev%2Fdiffo%2Fblob%2Fdev%2Fdiffo.livemd) -In this 'Diffo Provider Instance Extension' livebook you will learn about: +In this 'Diffo Provider Extension' livebook you will learn about: * TMF Services and Resources * Building your own Domain -* Declaring a Composite Resource +* Declaring a Composite Resource using the Instance Extension * Using the Assigner * Composing a Resource from partially assigned Resources +* Declaring domain Parties using the Party Extension ### Installing Neo4j and Configuring Bolty @@ -116,15 +120,7 @@ In all cases the assignment is only successful if the Provider allows the reques Partial resource assignment uses a relationship characteristic to indicate which part of the resource is optionally requested and ultimately assigned. -```elixir -Diffo.Provider.list_instances!() -``` - -## Building your own Domain - -Diffo is an open source, extensible, declarative, intent driven TMF Service and Resource Manager. - -In the Introductory Livebook we used the Diffo.Provider domain API and created basic TMF Services and Resources. While this is OK for getting to know Diffo, we actually want to build/declare domain specific functionality which means we will build our own [Ash.Domain](https://hexdocs.pm/ash/Ash.Domain.html). +## Instance Extension Diffo.Provider.Instance modelling a Service or Resource actually uses the Diffo.Provider.BaseInstance [Spark.Dsl.Fragment](https://hexdocs.pm/spark/Spark.Dsl.Fragment.html). There is no need to evaluate the Diffo.Provider.Instance below, it is already defined. @@ -150,7 +146,7 @@ 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 domain specific services and resources. -Currently it has DSL to allow you to declare specification, features and characteristics. It can be used for services or resources. +Currently it has DSL to allow you to declare specification, features, characteristics, and party roles. It can be used for services or resources. 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. @@ -159,57 +155,15 @@ We can still use the Diffo.Provider API's noting that they will return Diffo.Pro Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. -Each instance of Cluster could be created on Consumer demand as a 'container' for the GPI and NPU core partial resources. +Each instance of Cluster could be created on Consumer demand as a 'container' for the GPU and NPU core partial resources. -Each of the GPU and NPU Resource instances is created and managed by the Provider and is effectively a resource pool for individualy assignable cores. +Each of the GPU and NPU Resource instances is created and managed by the Provider and is effectively a resource pool for individually assignable cores. -We'll define these Resources in a Compute domain which exposes an API. +We'll define all the resources first, then declare the `Diffo.Compute` domain once they are all compiled — Ash validates `code_interface` at domain compile time so all referenced resources must exist first. -```elixir -defmodule Diffo.Compute do - @moduledoc """ - Diffo - TMF Service and Resource Management with a difference +### Declaring a Composite Resource - Compute - example domain - """ - use Ash.Domain, - otp_app: :diffo, - validate_config_inclusion?: false - - alias Diffo.Compute.GPU - #alias Diffo.Compute.NPU - alias Diffo.Compute.Cluster - - resources do - resource GPU do - define :get_gpu_by_id, action: :read, get_by: :id - define :build_gpu, action: :build - define :define_gpu, action: :define - define :relate_gpu, action: :relate - define :assign_gpu_core, action: :assign_core - end - - #resource NPU do - #define :get_npu_by_id, action: :read, get_by: :id - #define :build_npu, action: :build - #define :define_npu, action: :define - #define :relate_npu, action: :relate - #define :assign_npu_core, action: :assign_core - #end - - resource Cluster do - define :get_cluster_by_id, action: :read, get_by: :id - define :build_cluster, action: :build - define :define_cluster, action: :define - define :relate_cluster, action: :relate - end - end -end -``` - -## Declaring a Composite Resource - -We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the Diffo.Provide.BaseInstance fragment. +We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the Diffo.Provider.BaseInstance fragment. ```elixir defmodule Diffo.Compute.Cluster do @@ -225,6 +179,8 @@ defmodule Diffo.Compute.Cluster do alias Diffo.Provider.Instance.ActionHelper alias Diffo.Compute alias Diffo.Compute.ClusterValue + alias Diffo.Compute.Organisation + alias Diffo.Compute.Engineer use Ash.Resource, fragments: [BaseInstance], @@ -247,6 +203,11 @@ defmodule Diffo.Compute.Cluster do characteristic :cluster, ClusterValue end + parties do + party :operated_by, Organisation + party :managed_by, Engineer + end + actions do create :build do description "creates a new Cluster resource instance for build" @@ -307,12 +268,12 @@ defmodule Diffo.Compute.ClusterValue do use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] jason do - pick [:name, :sections, :length, :loss, :technology] + pick [:name, :gpu_cores, :npu_cores] compact true end outstanding do - expect [:loss] + expect [:gpu_cores] end typed_struct do @@ -337,50 +298,7 @@ defmodule Diffo.Compute.ClusterValue do end ``` -Now that should be enough to allow us to create a Cluster instance - -```elixir -defmodule Diffo.Compute.Test do - alias Diffo.Provider - alias Diffo.Provider.Instance.Place - alias Diffo.Provider.Instance.Party - - def create_exchange_place do - exchange = - Provider.create_place!(%{ - id: "DONC", - name: :exchangeId, - href: "place/telco/DONC", - referredType: :GeographicSite - }) - - %Place{id: exchange.id, role: :NetworkSite} - end - - def create_provider_party do - provider = - Provider.create_party!(%{ - id: "Compute", - name: :organizationId, - referredType: :Organization - }) - - %Party{id: provider.id, role: :Provider} - end -end - -places = [Diffo.Compute.Test.create_exchange_place()] -parties = [Diffo.Compute.Test.create_provider_party()] -cluster_1 = Diffo.Compute.build_cluster!(%{name: "cluster_1", places: places, parties: parties}) -``` - -We can render this Ash Resource as json: - -```elixir -Jason.encode!(cluster_1, pretty: true) |> IO.puts -``` - -## Using the Assigner +### Using the Assigner We'll now define a GPU Resource which uses the Diffo.Provider.Assigner functionality. @@ -490,7 +408,7 @@ defmodule Diffo.Compute.GPUValue do @moduledoc """ Diffo - TMF Service and Resource Management with a difference - CardValue - AshTyped Struct for GPU Characteristic Value + GPUValue - AshTyped Struct for GPU Characteristic Value """ use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] @@ -521,6 +439,186 @@ defmodule Diffo.Compute.GPUValue do end ``` +## Party Extension + +`Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `name`, `kind` — and the `Diffo.Provider.Party.Extension` DSL, which lets a Party kind declare the roles it plays with respect to Instances and other Parties. + +`kind` is either `:individual` or `:organization`. The `id` defaults to a generated uuid but can be set by the domain to any meaningful string (such as an ABN or a data centre identifier). + +The `Diffo.Provider.Party.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Party.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Party.Extension.html). + +### Defining Party kinds + +We'll add two Party kinds to our Compute domain — `Organisation` for the operating company, and `Engineer` for the individuals who manage resources. + +```elixir +defmodule Diffo.Compute.Organisation do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Organisation - an Organisation in the Compute domain + """ + + alias Diffo.Provider.BaseParty + alias Diffo.Compute + + use Ash.Resource, + fragments: [BaseParty], + domain: Compute + + resource do + description "A Compute Organisation" + plural_name :organisations + end + + instance do + role :operates, Diffo.Compute.Cluster + role :operates, Diffo.Compute.GPU + end +end +``` + +```elixir +defmodule Diffo.Compute.Engineer do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Engineer - an Engineer in the Compute domain + """ + + alias Diffo.Provider.BaseParty + alias Diffo.Compute + + use Ash.Resource, + fragments: [BaseParty], + domain: Compute + + resource do + description "A Compute Engineer" + plural_name :engineers + end + + instance do + role :manages, Diffo.Compute.Cluster + end + + party do + role :employed_by, Diffo.Compute.Organisation + end +end +``` + +### Compute Domain + +With all resources defined we can now declare the `Diffo.Compute` domain, which exposes a typed API for each resource: + +```elixir +defmodule Diffo.Compute do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Compute - example domain + """ + use Ash.Domain, + otp_app: :diffo, + validate_config_inclusion?: false + + alias Diffo.Compute.GPU + #alias Diffo.Compute.NPU + alias Diffo.Compute.Cluster + alias Diffo.Compute.Organisation + alias Diffo.Compute.Engineer + + resources do + resource GPU do + define :get_gpu_by_id, action: :read, get_by: :id + define :build_gpu, action: :build + define :define_gpu, action: :define + define :relate_gpu, action: :relate + define :assign_gpu_core, action: :assign_core + end + + #resource NPU do + #define :get_npu_by_id, action: :read, get_by: :id + #define :build_npu, action: :build + #define :define_npu, action: :define + #define :relate_npu, action: :relate + #define :assign_npu_core, action: :assign_core + #end + + resource Cluster do + define :get_cluster_by_id, action: :read, get_by: :id + define :build_cluster, action: :build + define :define_cluster, action: :define + define :relate_cluster, action: :relate + end + + resource Organisation do + define :create_organisation, action: :create + define :get_organisation_by_id, action: :read, get_by: :id + define :list_organisations, action: :list + end + + resource Engineer do + define :create_engineer, action: :create + define :get_engineer_by_id, action: :read, get_by: :id + define :list_engineers, action: :list + end + end +end +``` + +### Creating a Cluster + +Clear any data from previous runs before starting (safe to re-evaluate): + +```elixir +AshNeo4j.Neo4jHelper.delete_all() +``` + +Now the domain is defined we can create our first Cluster instance. We'll use a helper module to set up the place and provider party ref: + +```elixir +defmodule Diffo.Compute.Test do + alias Diffo.Provider + alias Diffo.Provider.Instance.Place + alias Diffo.Provider.Instance.Party + + def create_data_centre_place do + dc = + Provider.create_place!(%{ + id: "NXTM2", + name: :dataCentreId, + href: "place/compute/NXTM2", + referredType: :GeographicSite + }) + + %Place{id: dc.id, role: :DataCentre} + end + + def create_provider_party do + provider = + Provider.create_party!(%{ + id: "Compute", + name: :organizationId, + referredType: :Organization + }) + + %Party{id: provider.id, role: :Provider} + end +end + +places = [Diffo.Compute.Test.create_data_centre_place()] +parties = [Diffo.Compute.Test.create_provider_party()] +cluster_1 = Diffo.Compute.build_cluster!(%{name: "cluster_1", places: places, parties: parties}) +``` + +```elixir +Jason.encode!(cluster_1, pretty: true) |> IO.puts +``` + +### Using the Assigner + Now we'll create a couple of GPU instances: ```elixir @@ -548,7 +646,7 @@ The GPU's core characteristic is an AssignableValue, now we've allocated it we c Jason.encode!(gpu_1, pretty: true) |> IO.puts ``` -## Composing a Resource from partially assigned Resources +### Composing a Resource from partially assigned Resources Now we can auto-assign GPU cores from each GPU to our cluster_1. We'll assign 3 cores from gpu_1, and one from gpu_2. @@ -579,10 +677,74 @@ As an exercise, clone the GPU resource to create an NPU resource and assign some What happens when there are none left to assign? What happens when I request a specific assignment from an instance to which the partial resource is already assigned? +### Creating Party instances + +Now we can create an Organisation and an Engineer in the Compute domain. The `id` for the Organisation is set to a meaningful string — the company's ABN. + +```elixir +alias Diffo.Compute + +{:ok, org} = Compute.create_organisation(%{ + id: "51824753556", + name: "Acme Compute Pty Ltd", + kind: :organization +}) + +{:ok, engineer} = Compute.create_engineer(%{ + name: "Alice Zhang", + kind: :individual +}) +``` + +### Using Parties with a Cluster + +We can now build a Cluster and relate it to our Organisation and Engineer using the base provider party ref mechanism. + +```elixir +alias Diffo.Provider.Instance.Party + +parties = [ + %Party{id: org.id, role: :operated_by}, + %Party{id: engineer.id, role: :managed_by} +] + +cluster_2 = Compute.build_cluster!(%{ + name: "cluster_2", + places: [Diffo.Compute.Test.create_data_centre_place()], + parties: parties +}) + +Jason.encode!(cluster_2, pretty: true) |> IO.puts +``` + +### What the Party DSL declares + +The `instance do` and `party do` blocks are compile-time declarations — they document which roles a Party kind plays and make that information available via `Diffo.Provider.Party.Extension.Info`: + +```elixir +alias Diffo.Provider.Party.Extension.Info, as: PartyInfo + +PartyInfo.instance(Diffo.Compute.Organisation) +``` + +```elixir +PartyInfo.party(Diffo.Compute.Engineer) +``` + +These declarations also inform the Instance DSL's `parties do` block, which documents which party kinds an Instance resource expects: + +```elixir +alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo + +InstanceInfo.parties(Diffo.Compute.Cluster) +``` + ### What Next? -In this tutorial you've used Diffo's Provider Instance Extension to define a Compute domain with a composite Cluster resource which is comprised of assigned GPU cores from GPU resources and NPU cores from NPU resources. +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 Organisation 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. -The composite Cluster Resource is a fully fledged TMF Resource which can itself be related to consuming TMF Service and/or Resources. +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. If you find Diffo useful please visit and star on [github](https://github.com/diffo-dev/diffo/). Feel free to join discussions and raise issues to discuss PR's. diff --git a/lib/diffo/provider/components/base_party.ex b/lib/diffo/provider/components/base_party.ex new file mode 100644 index 0000000..f1f3c01 --- /dev/null +++ b/lib/diffo/provider/components/base_party.ex @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.BaseParty do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + BaseParty - Ash Resource Fragment of a TMF Party + + `BaseParty` is the foundation for domain-specific Party kinds such as RSP or Person. + It provides common Party attributes and the `Diffo.Provider.Party.Extension` DSL, which + allows a Party kind to declare the roles it plays with respect to Instances and other Parties. + + ## Usage + + defmodule MyApp.Organisation do + use Ash.Resource, fragments: [BaseParty], domain: MyApp.Domain + + resource do + description "An Organisation" + plural_name :organisations + end + + instance do + role :facilitates, MyApp.AccessService + end + end + + ## Action pattern + + Domain-specific Party resources should finish their `create` action with a reload via + their own domain's `get_xxx_by_id` to pick up extended fields: + + create :create do + accept [:name] + change after_action(fn _changeset, result, _context -> + MyApp.Domain.get_organisation_by_id(result.id) + end) + end + """ + + use Spark.Dsl.Fragment, + of: Ash.Resource, + otp_app: :diffo, + domain: Diffo.Provider, + data_layer: AshNeo4j.DataLayer, + extensions: [ + AshOutstanding.Resource, + AshJason.Resource, + Diffo.Provider.Party.Extension + ] + + neo4j do + relate [ + {:party_refs, :RELATES, :incoming, :PartyRef} + ] + + label :Party + end + + jason do + pick [:id, :name, :kind] + compact true + end + + outstanding do + expect [:id, :name, :kind] + end + + attributes do + attribute :id, :string do + description "the id of this party, domain-assigned or a generated uuid4 by default" + primary_key? true + allow_nil? false + public? true + default &Diffo.Uuid.uuid4/0 + source :key + end + + attribute :name, :string do + description "the name of this party" + allow_nil? true + public? true + end + + attribute :kind, :atom do + description "the kind of this party, either individual or organization" + allow_nil? false + public? true + constraints one_of: [:individual, :organization] + end + + create_timestamp :created_at + + update_timestamp :updated_at + end + + relationships do + has_many :party_refs, Diffo.Provider.PartyRef do + description "the party refs relating this party to instances" + destination_attribute :party_id + public? true + end + end + + actions do + defaults [:read, :destroy] + + create :create do + description "creates a party of this kind" + accept [:id, :name, :kind] + upsert? true + end + + update :update do + description "updates the party name" + accept [:name] + end + + read :list do + description "lists all parties of this kind" + end + + read :find_by_name do + description "finds parties by name" + get? false + + argument :query, :ci_string do + description "Return only parties with names including the given value." + end + + filter expr(contains(name, ^arg(:query))) + end + end +end diff --git a/lib/diffo/provider/components/instance/extension.ex b/lib/diffo/provider/components/instance/extension.ex index 18c6b73..f353f9d 100644 --- a/lib/diffo/provider/components/instance/extension.ex +++ b/lib/diffo/provider/components/instance/extension.ex @@ -152,6 +152,44 @@ defmodule Diffo.Provider.Instance.Extension do ] } + @party_entity %Spark.Dsl.Entity{ + name: :party, + describe: "Declares a party role on this Instance", + target: Diffo.Provider.Instance.Extension.PartyDeclaration, + args: [:role, :party_type], + schema: [ + role: [ + doc: """ + The role name, an atom + """, + type: :atom, + required: true + ], + party_type: [ + doc: """ + The module of the Party kind. An atom module name such as a BaseParty-derived resource. + """, + type: :any + ] + ] + } + + @parties %Spark.Dsl.Section{ + name: :parties, + describe: "List of Instance Party roles", + examples: [ + """ + parties do + party :facilitated_by, MyApp.Rsp + party :overseen_by, MyApp.Person + end + """ + ], + entities: [ + @party_entity + ] + } + use Spark.Dsl.Extension, - sections: [@specification, @features, @characteristics] + sections: [@specification, @features, @characteristics, @parties] end diff --git a/lib/diffo/provider/components/instance/extension/info.ex b/lib/diffo/provider/components/instance/extension/info.ex index 938a782..27c87d2 100644 --- a/lib/diffo/provider/components/instance/extension/info.ex +++ b/lib/diffo/provider/components/instance/extension/info.ex @@ -5,5 +5,5 @@ defmodule Diffo.Provider.Instance.Extension.Info do use Spark.InfoGenerator, extension: Diffo.Provider.Instance.Extension, - sections: [:specification, :features, :characteristics] + sections: [:specification, :features, :characteristics, :parties] end diff --git a/lib/diffo/provider/components/instance/extension/party_declaration.ex b/lib/diffo/provider/components/instance/extension/party_declaration.ex new file mode 100644 index 0000000..69fb67d --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/party_declaration.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.PartyDeclaration do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + PartyDeclaration - DSL entity declaring a party role on an Instance + """ + + defstruct [:role, :party_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/components/party/extension.ex b/lib/diffo/provider/components/party/extension.ex new file mode 100644 index 0000000..19fea32 --- /dev/null +++ b/lib/diffo/provider/components/party/extension.ex @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension do + @moduledoc """ + DSL Extension customising a Party + """ + + @role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party kind plays", + target: Diffo.Provider.Party.Extension.InstanceRole, + args: [:role, :party_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + party_type: [ + type: :any, + doc: "The module of the related resource" + ] + ] + } + + @party_role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party kind plays with respect to other Parties", + target: Diffo.Provider.Party.Extension.PartyRole, + args: [:role, :party_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + party_type: [ + type: :any, + doc: "The module of the related Party kind" + ] + ] + } + + @instance %Spark.Dsl.Section{ + name: :instance, + describe: "Declares the roles this Party kind plays with respect to Instances", + examples: [ + """ + instance do + role :facilitates, MyApp.AccessService + end + """ + ], + entities: [@role] + } + + @party %Spark.Dsl.Section{ + name: :party, + describe: "Declares the roles this Party kind plays with respect to other Parties", + examples: [ + """ + party do + role :managed_by, MyApp.Person + end + """ + ], + entities: [@party_role] + } + + use Spark.Dsl.Extension, + sections: [@instance, @party] +end diff --git a/lib/diffo/provider/components/party/extension/info.ex b/lib/diffo/provider/components/party/extension/info.ex new file mode 100644 index 0000000..66361d7 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/info.ex @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.Info do + use Spark.InfoGenerator, + extension: Diffo.Provider.Party.Extension, + sections: [:instance, :party] +end diff --git a/lib/diffo/provider/components/party/extension/instance_role.ex b/lib/diffo/provider/components/party/extension/instance_role.ex new file mode 100644 index 0000000..3a39545 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/instance_role.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.InstanceRole do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + InstanceRole - DSL entity declaring a role this Party kind plays with respect to Instances + """ + + defstruct [:role, :party_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/components/party/extension/party_role.ex b/lib/diffo/provider/components/party/extension/party_role.ex new file mode 100644 index 0000000..d4ba2ab --- /dev/null +++ b/lib/diffo/provider/components/party/extension/party_role.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.PartyRole do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + PartyRole - DSL entity declaring a role this Party kind plays with respect to other Parties + """ + + defstruct [:role, :party_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/repo.ex b/lib/diffo/repo.ex index 62bea87..937cfec 100644 --- a/lib/diffo/repo.ex +++ b/lib/diffo/repo.ex @@ -17,7 +17,9 @@ defmodule Diffo.Repo do end def start_link(_stack) do - config = Application.get_env(:bolty, Bolt) - Bolty.start_link(config) + case Application.get_env(:bolty, Bolt) do + nil -> :ignore + config -> Bolty.start_link(config) + end end end diff --git a/mix.exs b/mix.exs index cefb48b..c0e70ef 100644 --- a/mix.exs +++ b/mix.exs @@ -113,9 +113,10 @@ defmodule Diffo.MixProject do "docs", "spark.replace_doc_links" ], - "spark.cheat_sheets": "spark.cheat_sheets --extensions Diffo.Provider.Instance.Extension", + "spark.cheat_sheets": + "spark.cheat_sheets --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", "spark.formatter": [ - "spark.formatter --extensions Diffo.Provider.Instance.Extension", + "spark.formatter --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", "format .formatter.exs" ] ] diff --git a/test/instance_extension/party_test.exs b/test/instance_extension/party_test.exs new file mode 100644 index 0000000..9b91b9e --- /dev/null +++ b/test/instance_extension/party_test.exs @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.InstanceExtension.PartyTest do + @moduledoc false + use ExUnit.Case + + alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo + alias Diffo.Provider.Party.Extension.Info, as: PartyInfo + alias Diffo.Test.Organisation + alias Diffo.Test.Person + alias Diffo.Test.Shelf + alias Diffo.Test.Nbn + + setup_all do + AshNeo4j.BoltyHelper.start() + end + + setup do + on_exit(fn -> + AshNeo4j.Neo4jHelper.delete_all() + end) + end + + describe "Party DSL — Organisation" do + test "instance roles are declared" do + roles = PartyInfo.instance(Organisation) + assert length(roles) == 1 + assert hd(roles).role == :facilitates + assert hd(roles).party_type == Diffo.Provider.Instance + end + + test "no party roles declared" do + assert PartyInfo.party(Organisation) == [] + end + end + + describe "Party DSL — Person" do + test "party roles are declared" do + roles = PartyInfo.party(Person) + assert length(roles) == 1 + assert hd(roles).role == :managed_by + assert hd(roles).party_type == Diffo.Test.Person + end + + test "no instance roles declared" do + assert PartyInfo.instance(Person) == [] + end + end + + describe "Instance DSL — Shelf parties" do + test "party declarations are accessible via info" do + parties = InstanceInfo.parties(Shelf) + roles = Enum.map(parties, & &1.role) + assert :facilitated_by in roles + assert :overseen_by in roles + end + + test "party types are correct" do + parties = InstanceInfo.parties(Shelf) + facilitated_by = Enum.find(parties, &(&1.role == :facilitated_by)) + overseen_by = Enum.find(parties, &(&1.role == :overseen_by)) + assert facilitated_by.party_type == Organisation + assert overseen_by.party_type == Person + end + end + + describe "BaseParty — Organisation CRUD" do + test "create and read organisation" do + {:ok, org} = Nbn.create_organisation(%{name: "Acme Corp", kind: :organization}) + assert org.name == "Acme Corp" + assert org.kind == :organization + + {:ok, loaded} = Nbn.get_organisation_by_id(org.id) + assert loaded.name == "Acme Corp" + end + end + + describe "BaseParty — Person CRUD" do + test "create and read person" do + {:ok, person} = Nbn.create_person(%{name: "Alice", kind: :individual}) + assert person.name == "Alice" + assert person.kind == :individual + + {:ok, loaded} = Nbn.get_person_by_id(person.id) + assert loaded.name == "Alice" + end + end +end diff --git a/test/support/nbn.ex b/test/support/nbn.ex new file mode 100644 index 0000000..6ac259e --- /dev/null +++ b/test/support/nbn.ex @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Nbn do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Nbn - a lightweight example domain for testing BaseParty and Party DSL + """ + use Ash.Domain, + otp_app: :diffo, + validate_config_inclusion?: false + + alias Diffo.Test.Organisation + alias Diffo.Test.Person + + domain do + description "NBN party domain" + end + + resources do + resource Organisation do + define :create_organisation, action: :create + define :get_organisation_by_id, action: :read, get_by: :id + define :list_organisations, action: :list + end + + resource Person do + define :create_person, action: :create + define :get_person_by_id, action: :read, get_by: :id + define :list_persons, action: :list + end + end +end diff --git a/test/support/resource/organisation.ex b/test/support/resource/organisation.ex new file mode 100644 index 0000000..b911948 --- /dev/null +++ b/test/support/resource/organisation.ex @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Organisation do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Organisation - Organisation Party + """ + + alias Diffo.Provider.BaseParty + alias Diffo.Test.Nbn + + use Ash.Resource, + fragments: [BaseParty], + domain: Nbn + + resource do + description "An Organisation" + plural_name :organisations + end + + instance do + role :facilitates, Diffo.Provider.Instance + end +end diff --git a/test/support/resource/person.ex b/test/support/resource/person.ex new file mode 100644 index 0000000..ab27e2a --- /dev/null +++ b/test/support/resource/person.ex @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Person do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Person - Person Party + """ + + alias Diffo.Provider.BaseParty + alias Diffo.Test.Nbn + + use Ash.Resource, + fragments: [BaseParty], + domain: Nbn + + resource do + description "A Person" + plural_name :persons + end + + party do + role :managed_by, Diffo.Test.Person + end +end diff --git a/test/support/resource/shelf.ex b/test/support/resource/shelf.ex index b95724e..b9e1ecc 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/shelf.ex @@ -52,6 +52,11 @@ defmodule Diffo.Test.Shelf do characteristic :shelves, {:array, ShelfValue} end + parties do + party :facilitated_by, Diffo.Test.Organisation + party :overseen_by, Diffo.Test.Person + end + actions do create :build do description "creates a new Shelf resource instance for build"