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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,55 @@
"title": "AssignAssetPersistentIdResponse",
"type": "object"
},
"AssignFixturePersistentIdRequest": {
"description": "Body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.\n\n`scheme` selects the PID scheme; v1 supports DOI and HANDLE.\n`suffix` is the optional operator-supplied local part; when absent\nthe configured `DoiMinter` adapter auto-generates one. No `value`\nfield per the server-mint posture.",
"properties": {
"scheme": {
"$ref": "#/components/schemas/PersistentIdentifierScheme",
"description": "Closed PIDINST Property 1 scheme: DOI or HANDLE."
},
"suffix": {
"anyOf": [
{
"maxLength": 200,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"description": "Optional operator-supplied local part. When absent, the configured DoiMinter adapter auto-generates the suffix.",
"title": "Suffix"
}
},
"required": [
"scheme"
],
"title": "AssignFixturePersistentIdRequest",
"type": "object"
},
"AssignFixturePersistentIdResponse": {
"description": "Response body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.\n\nEchoes the server-minted `(scheme, value)` pair so the operator\nlearns the assigned identifier without a follow-up GET. Mirrors the\nAsset-tier sibling exactly; the value is server-minted and not\nderivable from the request alone.",
"properties": {
"scheme": {
"description": "Assigned PIDINST Property 1 scheme value (DOI or Handle).",
"title": "Scheme",
"type": "string"
},
"value": {
"description": "Authority-assigned persistent identifier string.",
"title": "Value",
"type": "string"
}
},
"required": [
"scheme",
"value"
],
"title": "AssignFixturePersistentIdResponse",
"type": "object"
},
"AttachAssetToFixtureRequest": {
"description": "Body for `POST /assets/{asset_id}/attach-to-fixture`.",
"properties": {
Expand Down Expand Up @@ -25815,6 +25864,123 @@
]
}
},
"/fixtures/{fixture_id}/assign-persistent-identifier": {
"post": {
"operationId": "post_fixtures_assign_persistent_identifier_fixtures__fixture_id__assign_persistent_identifier_post",
"parameters": [
{
"description": "Target fixture's id.",
"in": "path",
"name": "fixture_id",
"required": true,
"schema": {
"description": "Target fixture's id.",
"format": "uuid",
"title": "Fixture Id",
"type": "string"
}
},
{
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"in": "header",
"name": "X-Principal-Id",
"required": false,
"schema": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"title": "X-Principal-Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignFixturePersistentIdRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignFixturePersistentIdResponse"
}
}
},
"description": "Successful Response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "PersistentIdentifier VO validation failed: empty or whitespace-only value, or value over the max-length bound (InvalidPersistentIdentifierValueError)."
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Authorize policy denied the command."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "No fixture exists with the given id."
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Fixture cannot accept the persistent identifier: the fixture already carries a persistent_id (set-once: FixturePersistentIdAlreadyAssignedError), OR a concurrent write to the same fixture stream conflicted (optimistic concurrency)."
},
"422": {
"description": "Path parameter or request body failed schema validation (missing field, malformed UUID, scheme outside the closed enum, suffix length out of bounds at the wire layer)."
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "The external mint authority (DataCite or Handle.net) failed to assign a persistent identifier (PersistentIdentifierMintError)."
}
},
"summary": "Assign a PIDINST persistent identifier to an existing Fixture",
"tags": [
"equipment"
]
}
},
"/fixtures/{fixture_id}/pidinst": {
"get": {
"operationId": "get_fixture_pidinst_fixtures__fixture_id__pidinst_get",
Expand Down
71 changes: 71 additions & 0 deletions apps/api/src/cora/equipment/_fixture_persistent_identifier_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Shared Pydantic wire-format for the `assign_fixture_persistent_id` slice.

Mirrors `_asset_persistent_identifier_body` at the BC root. Per Section
6.5 of [[project-fixture-pidinst-design]], slice F at the Fixture tier
does NOT carry a domain-VO wire mirror (the `PersistentIdentifier` VO
is never parsed at the wire; it is server-minted inside the handler).
This module instead carries the request + response wire surface for the
single POST endpoint:

- `AssignFixturePersistentIdRequest`: `(scheme, suffix | None)`
operator intent. The handler resolves the suffix into a full
`PersistentIdentifier` via the configured `DoiMinter` port.
- `AssignFixturePersistentIdResponse`: `(scheme, value)` echoed back
so the operator learns the server-minted identifier without a
follow-up GET (Section 6.5 deviation from the empty-201 convention
for Fixture mutations).

Reuses `PersistentIdentifierScheme` + `PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH`
from the Asset module per Locks 2-4 of the design memo (the VO + enum
ship at the Asset tier and the Fixture tier imports them unchanged).
"""

from pydantic import BaseModel, Field

from cora.equipment.aggregates.asset import (
PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH,
PersistentIdentifierScheme,
)


class AssignFixturePersistentIdRequest(BaseModel):
"""Body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.

`scheme` selects the PID scheme; v1 supports DOI and HANDLE.
`suffix` is the optional operator-supplied local part; when absent
the configured `DoiMinter` adapter auto-generates one. No `value`
field per the server-mint posture.
"""

scheme: PersistentIdentifierScheme = Field(
...,
description="Closed PIDINST Property 1 scheme: DOI or HANDLE.",
)
suffix: str | None = Field(
None,
min_length=1,
max_length=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH,
description=(
"Optional operator-supplied local part. When absent, the "
"configured DoiMinter adapter auto-generates the suffix."
),
)


class AssignFixturePersistentIdResponse(BaseModel):
"""Response body for `POST /fixtures/{fixture_id}/assign-persistent-identifier`.

Echoes the server-minted `(scheme, value)` pair so the operator
learns the assigned identifier without a follow-up GET. Mirrors the
Asset-tier sibling exactly; the value is server-minted and not
derivable from the request alone.
"""

scheme: str = Field(
...,
description="Assigned PIDINST Property 1 scheme value (DOI or Handle).",
)
value: str = Field(
...,
description="Authority-assigned persistent identifier string.",
)
6 changes: 6 additions & 0 deletions apps/api/src/cora/equipment/aggregates/fixture/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from cora.equipment.aggregates.fixture.events import (
FixtureEvent,
FixturePersistentIdAssigned,
FixtureRegistered,
event_type_name,
from_stored,
Expand All @@ -23,6 +24,8 @@
Fixture,
FixtureAlreadyExistsError,
FixtureNotFoundError,
FixturePersistentIdAlreadyAssignedError,
MalformedFixturePersistentIdentifierError,
PersistentIdentifier,
SlotAssetBinding,
)
Expand All @@ -32,7 +35,10 @@
"FixtureAlreadyExistsError",
"FixtureEvent",
"FixtureNotFoundError",
"FixturePersistentIdAlreadyAssignedError",
"FixturePersistentIdAssigned",
"FixtureRegistered",
"MalformedFixturePersistentIdentifierError",
"PersistentIdentifier",
"SlotAssetBinding",
"event_type_name",
Expand Down
67 changes: 65 additions & 2 deletions apps/api/src/cora/equipment/aggregates/fixture/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
from typing import Any, assert_never
from uuid import UUID

from cora.equipment.aggregates.fixture.state import SlotAssetBinding
from cora.equipment.aggregates.asset import PersistentIdentifierScheme
from cora.equipment.aggregates.fixture.state import (
MalformedFixturePersistentIdentifierError,
SlotAssetBinding,
)
from cora.infrastructure.event_payload import deserialize_or_raise
from cora.infrastructure.ports.event_store import StoredEvent

Expand Down Expand Up @@ -63,7 +67,32 @@ class FixtureRegistered:
occurred_at: datetime


FixtureEvent = FixtureRegistered
@dataclass(frozen=True)
class FixturePersistentIdAssigned:
"""A persistent identifier (PIDINST v1.0 Property 1) was assigned to a Fixture.

Single-assign event. Set-once at the aggregate level: the
decider's `FixturePersistentIdAlreadyAssignedError` enforces "must
currently be absent" at command time, so the stream can contain
AT MOST ONE `FixturePersistentIdAssigned` event per Fixture.

The full `PersistentIdentifier` VO (scheme + value) travels in the
payload as two primitives, mirroring `AssetPersistentIdAssigned`:
scheme is the StrEnum value, value is the trimmed string. This
lets `from_stored` rebuild the VO without reading prior state.

No `withdrawn_at` / `withdrawal_reason` on this event: this slice does
not model withdrawal. A future slice owns the withdrawal sibling
event when operator demand fires.
"""

fixture_id: UUID
persistent_id_scheme: str
persistent_id_value: str
occurred_at: datetime


FixtureEvent = FixtureRegistered | FixturePersistentIdAssigned


def event_type_name(event: FixtureEvent) -> str:
Expand Down Expand Up @@ -95,6 +124,18 @@ def to_payload(event: FixtureEvent) -> dict[str, Any]:
"parameter_overrides": parameter_overrides,
"occurred_at": occurred_at.isoformat(),
}
case FixturePersistentIdAssigned(
fixture_id=fixture_id,
persistent_id_scheme=scheme,
persistent_id_value=value,
occurred_at=occurred_at,
):
return {
"fixture_id": str(fixture_id),
"persistent_id_scheme": scheme,
"persistent_id_value": value,
"occurred_at": occurred_at.isoformat(),
}
case _: # pragma: no cover # exhaustiveness guard
assert_never(event)

Expand All @@ -119,13 +160,35 @@ def _build() -> FixtureRegistered:
)

return deserialize_or_raise("FixtureRegistered", _build)
case "FixturePersistentIdAssigned":

def _build_persistent_id_assigned() -> FixturePersistentIdAssigned:
scheme = PersistentIdentifierScheme(payload["persistent_id_scheme"])
value = payload["persistent_id_value"]
if not isinstance(value, str) or not value.strip():
raise MalformedFixturePersistentIdentifierError(
f"persistent_id_value must be a non-empty string (got: {value!r})"
)
return FixturePersistentIdAssigned(
fixture_id=UUID(payload["fixture_id"]),
persistent_id_scheme=scheme.value,
persistent_id_value=value,
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
)

return deserialize_or_raise(
"FixturePersistentIdAssigned",
_build_persistent_id_assigned,
extra=(ValueError, MalformedFixturePersistentIdentifierError),
)
case _:
msg = f"Unknown FixtureEvent event_type: {stored.event_type!r}"
raise ValueError(msg)


__all__ = [
"FixtureEvent",
"FixturePersistentIdAssigned",
"FixtureRegistered",
"event_type_name",
"from_stored",
Expand Down
Loading
Loading