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
175 changes: 175 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,55 @@
"title": "AssetSummaryDTO",
"type": "object"
},
"AssignAssetPersistentIdRequest": {
"description": "Body for `POST /assets/{asset_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 (Lock 12).",
"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; the bare-request flow is the common case for retrospective bulk-mint per F1.4.",
"title": "Suffix"
}
},
"required": [
"scheme"
],
"title": "AssignAssetPersistentIdRequest",
"type": "object"
},
"AssignAssetPersistentIdResponse": {
"description": "Response body for `POST /assets/{asset_id}/assign-persistent-identifier`.\n\nEchoes the server-minted `(scheme, value)` pair so the operator\nlearns the assigned identifier without a follow-up GET. Per Lock\n17, this is the only Asset POST that returns a structured body;\nthe deviation is justified because the value is server-minted and\nnot derivable 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": "AssignAssetPersistentIdResponse",
"type": "object"
},
"AttachAssetToFixtureRequest": {
"description": "Body for `POST /assets/{asset_id}/attach-to-fixture`.",
"properties": {
Expand Down Expand Up @@ -7108,6 +7157,15 @@
"title": "PermitSummaryDTO",
"type": "object"
},
"PersistentIdentifierScheme": {
"description": "Closed PIDINST v1.0 Property 1 identifier-type vocabulary (subset).\n\nValues match `PidinstIdentifierType.DOI.value` and\n`PidinstIdentifierType.HANDLE.value` byte-for-byte so the\nserializer swap (URN to DOI / Handle) does not need a translation\nmap. URN and URL members of `PidinstIdentifierType` are\nintentionally NOT mirrored here: `Asset.persistent_id` is an\nassigned-by-operator persistent identifier, not a runtime fallback\nor a content URL.\n\nAdding a fourth member (for example ARK or PURL) is an additive\nenum change at a future migration boundary, gated on operator\ndemand. The closed-enum stance mirrors `AlternateIdentifierKind`\nand `ManufacturerIdentifierType`.",
"enum": [
"DOI",
"Handle"
],
"title": "PersistentIdentifierScheme",
"type": "string"
},
"PidinstAlternateIdentifierDTO": {
"description": "PIDINST v1.0 Property 13: an alternate identifier under a known scheme.",
"properties": {
Expand Down Expand Up @@ -16024,6 +16082,123 @@
]
}
},
"/assets/{asset_id}/assign-persistent-identifier": {
"post": {
"operationId": "post_assets_assign_persistent_identifier_assets__asset_id__assign_persistent_identifier_post",
"parameters": [
{
"description": "Target asset's id.",
"in": "path",
"name": "asset_id",
"required": true,
"schema": {
"description": "Target asset's id.",
"format": "uuid",
"title": "Asset 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/AssignAssetPersistentIdRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignAssetPersistentIdResponse"
}
}
},
"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 asset exists with the given id."
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Asset cannot accept the persistent identifier under current conditions: the asset is Decommissioned (AssetPersistentIdAssignmentForbiddenError), OR the asset already carries a persistent_id (set-once: AssetPersistentIdAlreadyAssignedError), OR a concurrent write to the same asset 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 Asset",
"tags": [
"equipment"
]
}
},
"/assets/{asset_id}/attach-to-fixture": {
"post": {
"operationId": "post_assets_attach_to_fixture_assets__asset_id__attach_to_fixture_post",
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/cora/equipment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,33 @@
"""

from cora.equipment._projections import register_equipment_projections
from cora.equipment.aggregates.asset import (
AssetPersistentIdAlreadyAssignedError,
AssetPersistentIdAssigned,
AssetPersistentIdAssignmentForbiddenError,
InvalidPersistentIdentifierValueError,
PersistentIdentifier,
PersistentIdentifierScheme,
)
from cora.equipment.errors import UnauthorizedError
from cora.equipment.ports.doi_minter import (
DoiMinter,
PersistentIdentifierMintError,
)
from cora.equipment.routes import register_equipment_routes
from cora.equipment.tools import register_equipment_tools
from cora.equipment.wire import EquipmentHandlers, wire_equipment

__all__ = [
"AssetPersistentIdAlreadyAssignedError",
"AssetPersistentIdAssigned",
"AssetPersistentIdAssignmentForbiddenError",
"DoiMinter",
"EquipmentHandlers",
"InvalidPersistentIdentifierValueError",
"PersistentIdentifier",
"PersistentIdentifierMintError",
"PersistentIdentifierScheme",
"UnauthorizedError",
"register_equipment_projections",
"register_equipment_routes",
Expand Down
71 changes: 71 additions & 0 deletions apps/api/src/cora/equipment/_asset_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_asset_persistent_id` slice.

Per Lock 22 of [[project-asset-persistent-id-write-design]], slice F
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:

- `AssignAssetPersistentIdRequest`: `(scheme, suffix | None)` operator
intent. The handler resolves the suffix into a full
`PersistentIdentifier` via the configured `DoiMinter` port.
- `AssignAssetPersistentIdResponse`: `(scheme, value)` echoed back so the
operator learns the server-minted identifier without a follow-up
GET (Lock 17 deviation from the empty-201 convention).

Mirrors the placement of `_asset_owner_body` and
`_alternate_identifier_body` at the BC root.
"""

from pydantic import BaseModel, Field

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


class AssignAssetPersistentIdRequest(BaseModel):
"""Body for `POST /assets/{asset_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 (Lock 12).
"""

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; "
"the bare-request flow is the common case for retrospective "
"bulk-mint per F1.4."
),
)


class AssignAssetPersistentIdResponse(BaseModel):
"""Response body for `POST /assets/{asset_id}/assign-persistent-identifier`.

Echoes the server-minted `(scheme, value)` pair so the operator
learns the assigned identifier without a follow-up GET. Per Lock
17, this is the only Asset POST that returns a structured body;
the deviation is justified because 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.",
)
16 changes: 12 additions & 4 deletions apps/api/src/cora/equipment/_pidinst_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
RelatedIdentifier,
SchemaVersion,
)
from cora.equipment.aggregates.asset import PersistentIdentifierScheme
from cora.equipment.errors import (
AssetNameMissingError,
LandingPageMissingError,
Expand Down Expand Up @@ -151,10 +152,17 @@ def _validate_manufacturer_state_available(view: AssetPidinstView) -> None:


def _build_identifier(view: AssetPidinstView) -> PidinstIdentifier:
return PidinstIdentifier(
value=f"{_URN_UUID_PREFIX}{view.asset_id}",
scheme=PidinstIdentifierType.URN,
)
if view.persistent_id is None:
return PidinstIdentifier(
value=f"{_URN_UUID_PREFIX}{view.asset_id}",
scheme=PidinstIdentifierType.URN,
)
match view.persistent_id.scheme:
case PersistentIdentifierScheme.DOI:
wire_scheme = PidinstIdentifierType.DOI
case PersistentIdentifierScheme.HANDLE:
wire_scheme = PidinstIdentifierType.HANDLE
return PidinstIdentifier(value=view.persistent_id.value, scheme=wire_scheme)


def _build_landing_page(view: AssetPidinstView) -> str:
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/equipment/_pidinst_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
AlternateIdentifier,
AlternateIdentifierKind,
AssetLifecycle,
PersistentIdentifier,
)
from cora.equipment.aggregates.model import ManufacturerIdentifierType
from cora.equipment.errors import PidinstRecordInvariantError
Expand Down Expand Up @@ -237,6 +238,7 @@ class AssetPidinstView:
publisher: str
publication_year: int | None
owners: tuple[Owner, ...]
persistent_id: PersistentIdentifier | None = None


@dataclass(frozen=True)
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/cora/equipment/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Equipment BC adapters.

`StubDoiMinter` is the test-tier `DoiMinter` adapter per
[[project-asset-persistent-id-write-design]] (slice F.1): a real
adapter that returns inert deterministic values, distinct from a
None / disabled port. Mirrors `AllowAllAuthorize` and
`AlwaysCoveredClearanceLookup` test-bypass convention. The
production `DataCiteDoiMinter` adapter is deferred to slice F.2.
"""
Loading
Loading