Skip to content

Commit e74031a

Browse files
authored
Merge pull request #38 from xmap/asset-persistent-id-slice-f
feat(equipment): Asset.persistent_id write path + DoiMinter port + Stub adapter (slice F.1)
2 parents adb5060 + fe9430b commit e74031a

41 files changed

Lines changed: 3944 additions & 6 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/openapi.json

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,55 @@
13711371
"title": "AssetSummaryDTO",
13721372
"type": "object"
13731373
},
1374+
"AssignAssetPersistentIdRequest": {
1375+
"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).",
1376+
"properties": {
1377+
"scheme": {
1378+
"$ref": "#/components/schemas/PersistentIdentifierScheme",
1379+
"description": "Closed PIDINST Property 1 scheme: DOI or HANDLE."
1380+
},
1381+
"suffix": {
1382+
"anyOf": [
1383+
{
1384+
"maxLength": 200,
1385+
"minLength": 1,
1386+
"type": "string"
1387+
},
1388+
{
1389+
"type": "null"
1390+
}
1391+
],
1392+
"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.",
1393+
"title": "Suffix"
1394+
}
1395+
},
1396+
"required": [
1397+
"scheme"
1398+
],
1399+
"title": "AssignAssetPersistentIdRequest",
1400+
"type": "object"
1401+
},
1402+
"AssignAssetPersistentIdResponse": {
1403+
"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.",
1404+
"properties": {
1405+
"scheme": {
1406+
"description": "Assigned PIDINST Property 1 scheme value (DOI or Handle).",
1407+
"title": "Scheme",
1408+
"type": "string"
1409+
},
1410+
"value": {
1411+
"description": "Authority-assigned persistent identifier string.",
1412+
"title": "Value",
1413+
"type": "string"
1414+
}
1415+
},
1416+
"required": [
1417+
"scheme",
1418+
"value"
1419+
],
1420+
"title": "AssignAssetPersistentIdResponse",
1421+
"type": "object"
1422+
},
13741423
"AttachAssetToFixtureRequest": {
13751424
"description": "Body for `POST /assets/{asset_id}/attach-to-fixture`.",
13761425
"properties": {
@@ -7108,6 +7157,15 @@
71087157
"title": "PermitSummaryDTO",
71097158
"type": "object"
71107159
},
7160+
"PersistentIdentifierScheme": {
7161+
"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`.",
7162+
"enum": [
7163+
"DOI",
7164+
"Handle"
7165+
],
7166+
"title": "PersistentIdentifierScheme",
7167+
"type": "string"
7168+
},
71117169
"PidinstAlternateIdentifierDTO": {
71127170
"description": "PIDINST v1.0 Property 13: an alternate identifier under a known scheme.",
71137171
"properties": {
@@ -16024,6 +16082,123 @@
1602416082
]
1602516083
}
1602616084
},
16085+
"/assets/{asset_id}/assign-persistent-identifier": {
16086+
"post": {
16087+
"operationId": "post_assets_assign_persistent_identifier_assets__asset_id__assign_persistent_identifier_post",
16088+
"parameters": [
16089+
{
16090+
"description": "Target asset's id.",
16091+
"in": "path",
16092+
"name": "asset_id",
16093+
"required": true,
16094+
"schema": {
16095+
"description": "Target asset's id.",
16096+
"format": "uuid",
16097+
"title": "Asset Id",
16098+
"type": "string"
16099+
}
16100+
},
16101+
{
16102+
"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.",
16103+
"in": "header",
16104+
"name": "X-Principal-Id",
16105+
"required": false,
16106+
"schema": {
16107+
"anyOf": [
16108+
{
16109+
"format": "uuid",
16110+
"type": "string"
16111+
},
16112+
{
16113+
"type": "null"
16114+
}
16115+
],
16116+
"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.",
16117+
"title": "X-Principal-Id"
16118+
}
16119+
}
16120+
],
16121+
"requestBody": {
16122+
"content": {
16123+
"application/json": {
16124+
"schema": {
16125+
"$ref": "#/components/schemas/AssignAssetPersistentIdRequest"
16126+
}
16127+
}
16128+
},
16129+
"required": true
16130+
},
16131+
"responses": {
16132+
"201": {
16133+
"content": {
16134+
"application/json": {
16135+
"schema": {
16136+
"$ref": "#/components/schemas/AssignAssetPersistentIdResponse"
16137+
}
16138+
}
16139+
},
16140+
"description": "Successful Response"
16141+
},
16142+
"400": {
16143+
"content": {
16144+
"application/json": {
16145+
"schema": {
16146+
"$ref": "#/components/schemas/ErrorResponse"
16147+
}
16148+
}
16149+
},
16150+
"description": "PersistentIdentifier VO validation failed: empty or whitespace-only value, or value over the max-length bound (InvalidPersistentIdentifierValueError)."
16151+
},
16152+
"403": {
16153+
"content": {
16154+
"application/json": {
16155+
"schema": {
16156+
"$ref": "#/components/schemas/ErrorResponse"
16157+
}
16158+
}
16159+
},
16160+
"description": "Authorize policy denied the command."
16161+
},
16162+
"404": {
16163+
"content": {
16164+
"application/json": {
16165+
"schema": {
16166+
"$ref": "#/components/schemas/ErrorResponse"
16167+
}
16168+
}
16169+
},
16170+
"description": "No asset exists with the given id."
16171+
},
16172+
"409": {
16173+
"content": {
16174+
"application/json": {
16175+
"schema": {
16176+
"$ref": "#/components/schemas/ErrorResponse"
16177+
}
16178+
}
16179+
},
16180+
"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)."
16181+
},
16182+
"422": {
16183+
"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)."
16184+
},
16185+
"502": {
16186+
"content": {
16187+
"application/json": {
16188+
"schema": {
16189+
"$ref": "#/components/schemas/ErrorResponse"
16190+
}
16191+
}
16192+
},
16193+
"description": "The external mint authority (DataCite or Handle.net) failed to assign a persistent identifier (PersistentIdentifierMintError)."
16194+
}
16195+
},
16196+
"summary": "Assign a PIDINST persistent identifier to an existing Asset",
16197+
"tags": [
16198+
"equipment"
16199+
]
16200+
}
16201+
},
1602716202
"/assets/{asset_id}/attach-to-fixture": {
1602816203
"post": {
1602916204
"operationId": "post_assets_attach_to_fixture_assets__asset_id__attach_to_fixture_post",

apps/api/src/cora/equipment/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,33 @@
3636
"""
3737

3838
from cora.equipment._projections import register_equipment_projections
39+
from cora.equipment.aggregates.asset import (
40+
AssetPersistentIdAlreadyAssignedError,
41+
AssetPersistentIdAssigned,
42+
AssetPersistentIdAssignmentForbiddenError,
43+
InvalidPersistentIdentifierValueError,
44+
PersistentIdentifier,
45+
PersistentIdentifierScheme,
46+
)
3947
from cora.equipment.errors import UnauthorizedError
48+
from cora.equipment.ports.doi_minter import (
49+
DoiMinter,
50+
PersistentIdentifierMintError,
51+
)
4052
from cora.equipment.routes import register_equipment_routes
4153
from cora.equipment.tools import register_equipment_tools
4254
from cora.equipment.wire import EquipmentHandlers, wire_equipment
4355

4456
__all__ = [
57+
"AssetPersistentIdAlreadyAssignedError",
58+
"AssetPersistentIdAssigned",
59+
"AssetPersistentIdAssignmentForbiddenError",
60+
"DoiMinter",
4561
"EquipmentHandlers",
62+
"InvalidPersistentIdentifierValueError",
63+
"PersistentIdentifier",
64+
"PersistentIdentifierMintError",
65+
"PersistentIdentifierScheme",
4666
"UnauthorizedError",
4767
"register_equipment_projections",
4868
"register_equipment_routes",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Shared Pydantic wire-format for the `assign_asset_persistent_id` slice.
2+
3+
Per Lock 22 of [[project-asset-persistent-id-write-design]], slice F
4+
does NOT carry a domain-VO wire mirror (the `PersistentIdentifier` VO
5+
is never parsed at the wire; it is server-minted inside the handler).
6+
This module instead carries the request + response wire surface for
7+
the single POST endpoint:
8+
9+
- `AssignAssetPersistentIdRequest`: `(scheme, suffix | None)` operator
10+
intent. The handler resolves the suffix into a full
11+
`PersistentIdentifier` via the configured `DoiMinter` port.
12+
- `AssignAssetPersistentIdResponse`: `(scheme, value)` echoed back so the
13+
operator learns the server-minted identifier without a follow-up
14+
GET (Lock 17 deviation from the empty-201 convention).
15+
16+
Mirrors the placement of `_asset_owner_body` and
17+
`_alternate_identifier_body` at the BC root.
18+
"""
19+
20+
from pydantic import BaseModel, Field
21+
22+
from cora.equipment.aggregates.asset import (
23+
PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH,
24+
PersistentIdentifierScheme,
25+
)
26+
27+
28+
class AssignAssetPersistentIdRequest(BaseModel):
29+
"""Body for `POST /assets/{asset_id}/assign-persistent-identifier`.
30+
31+
`scheme` selects the PID scheme; v1 supports DOI and HANDLE.
32+
`suffix` is the optional operator-supplied local part; when absent
33+
the configured `DoiMinter` adapter auto-generates one. No `value`
34+
field per the server-mint posture (Lock 12).
35+
"""
36+
37+
scheme: PersistentIdentifierScheme = Field(
38+
...,
39+
description="Closed PIDINST Property 1 scheme: DOI or HANDLE.",
40+
)
41+
suffix: str | None = Field(
42+
None,
43+
min_length=1,
44+
max_length=PERSISTENT_IDENTIFIER_VALUE_MAX_LENGTH,
45+
description=(
46+
"Optional operator-supplied local part. When absent, the "
47+
"configured DoiMinter adapter auto-generates the suffix; "
48+
"the bare-request flow is the common case for retrospective "
49+
"bulk-mint per F1.4."
50+
),
51+
)
52+
53+
54+
class AssignAssetPersistentIdResponse(BaseModel):
55+
"""Response body for `POST /assets/{asset_id}/assign-persistent-identifier`.
56+
57+
Echoes the server-minted `(scheme, value)` pair so the operator
58+
learns the assigned identifier without a follow-up GET. Per Lock
59+
17, this is the only Asset POST that returns a structured body;
60+
the deviation is justified because the value is server-minted and
61+
not derivable from the request alone.
62+
"""
63+
64+
scheme: str = Field(
65+
...,
66+
description="Assigned PIDINST Property 1 scheme value (DOI or Handle).",
67+
)
68+
value: str = Field(
69+
...,
70+
description="Authority-assigned persistent identifier string.",
71+
)

apps/api/src/cora/equipment/_pidinst_serializer.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
RelatedIdentifier,
7575
SchemaVersion,
7676
)
77+
from cora.equipment.aggregates.asset import PersistentIdentifierScheme
7778
from cora.equipment.errors import (
7879
AssetNameMissingError,
7980
LandingPageMissingError,
@@ -151,10 +152,17 @@ def _validate_manufacturer_state_available(view: AssetPidinstView) -> None:
151152

152153

153154
def _build_identifier(view: AssetPidinstView) -> PidinstIdentifier:
154-
return PidinstIdentifier(
155-
value=f"{_URN_UUID_PREFIX}{view.asset_id}",
156-
scheme=PidinstIdentifierType.URN,
157-
)
155+
if view.persistent_id is None:
156+
return PidinstIdentifier(
157+
value=f"{_URN_UUID_PREFIX}{view.asset_id}",
158+
scheme=PidinstIdentifierType.URN,
159+
)
160+
match view.persistent_id.scheme:
161+
case PersistentIdentifierScheme.DOI:
162+
wire_scheme = PidinstIdentifierType.DOI
163+
case PersistentIdentifierScheme.HANDLE:
164+
wire_scheme = PidinstIdentifierType.HANDLE
165+
return PidinstIdentifier(value=view.persistent_id.value, scheme=wire_scheme)
158166

159167

160168
def _build_landing_page(view: AssetPidinstView) -> str:

apps/api/src/cora/equipment/_pidinst_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
AlternateIdentifier,
4848
AlternateIdentifierKind,
4949
AssetLifecycle,
50+
PersistentIdentifier,
5051
)
5152
from cora.equipment.aggregates.model import ManufacturerIdentifierType
5253
from cora.equipment.errors import PidinstRecordInvariantError
@@ -237,6 +238,7 @@ class AssetPidinstView:
237238
publisher: str
238239
publication_year: int | None
239240
owners: tuple[Owner, ...]
241+
persistent_id: PersistentIdentifier | None = None
240242

241243

242244
@dataclass(frozen=True)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Equipment BC adapters.
2+
3+
`StubDoiMinter` is the test-tier `DoiMinter` adapter per
4+
[[project-asset-persistent-id-write-design]] (slice F.1): a real
5+
adapter that returns inert deterministic values, distinct from a
6+
None / disabled port. Mirrors `AllowAllAuthorize` and
7+
`AlwaysCoveredClearanceLookup` test-bypass convention. The
8+
production `DataCiteDoiMinter` adapter is deferred to slice F.2.
9+
"""

0 commit comments

Comments
 (0)