fairagro-middleware-shared 9.0.1.dev13__tar.gz → 9.1.1.dev14__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/.gitignore +3 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/PKG-INFO +1 -1
- fairagro_middleware_shared-9.1.1.dev14/src/middleware/shared/api_models/common/rocrate.py +86 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v2/models.py +2 -1
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v3/models.py +3 -2
- fairagro_middleware_shared-9.1.1.dev14/tests/unit/test_rocrate.py +121 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/README.md +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/pyproject.toml +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/common/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/common/models.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/py.typed +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v1/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v1/models.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v2/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v3/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/__init__.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/config_base.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/config_wrapper.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/logging.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/py.typed +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/tracing.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_api_models.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_config_base.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_config_wrapper.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_logging.py +0 -0
- {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_tracing.py +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""RO-Crate payload validation for ARC upload requests."""
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import Annotated, Any, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _root_dataset_entity(graph: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
10
|
+
"""Return the RO-Crate root data entity (``@id``: ``./``) from ``@graph``."""
|
|
11
|
+
for item in graph:
|
|
12
|
+
if isinstance(item, dict) and item.get("@id") == "./":
|
|
13
|
+
return item
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_text_field(value: object) -> str | None:
|
|
18
|
+
if isinstance(value, list):
|
|
19
|
+
value = value[0] if value else None
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
text = str(value).strip()
|
|
23
|
+
return text or None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _extract_identifier(root: dict[str, Any]) -> str:
|
|
27
|
+
"""Extract and normalize the required ``identifier`` from the root data entity."""
|
|
28
|
+
identifier = _normalize_text_field(root.get("identifier"))
|
|
29
|
+
if not identifier:
|
|
30
|
+
msg = "RO-Crate root data entity must contain a non-empty identifier"
|
|
31
|
+
raise ValueError(msg)
|
|
32
|
+
return identifier
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_optional_text(root: dict[str, Any], field: str) -> str | None:
|
|
36
|
+
"""Extract an optional text field from the root data entity."""
|
|
37
|
+
return _normalize_text_field(root.get(field))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_root_dataset(graph: list[dict[str, Any]]) -> dict[str, Any]:
|
|
41
|
+
"""Validate ``@graph`` contains a root data entity with a non-empty ``identifier``."""
|
|
42
|
+
root = _root_dataset_entity(graph)
|
|
43
|
+
if root is None:
|
|
44
|
+
msg = "RO-Crate must contain a root data entity with @id './'"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
_extract_identifier(root)
|
|
47
|
+
return root
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RoCratePayload(BaseModel):
|
|
51
|
+
"""RO-Crate metadata document as received on the API wire format."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
54
|
+
|
|
55
|
+
context: Annotated[Any, Field(alias="@context")]
|
|
56
|
+
graph: Annotated[list[dict[str, Any]], Field(alias="@graph", min_length=1)]
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def validate_root_dataset_fields(self) -> Self:
|
|
60
|
+
"""Require a root data entity with API-mandated fields; leave other properties untouched."""
|
|
61
|
+
validate_root_dataset(self.graph)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
@cached_property
|
|
65
|
+
def _root(self) -> dict[str, Any]:
|
|
66
|
+
"""Root data entity dict (validated during model construction)."""
|
|
67
|
+
root = _root_dataset_entity(self.graph)
|
|
68
|
+
if root is None:
|
|
69
|
+
msg = "RO-Crate must contain a root data entity with @id './'"
|
|
70
|
+
raise ValueError(msg)
|
|
71
|
+
return root
|
|
72
|
+
|
|
73
|
+
@cached_property
|
|
74
|
+
def identifier(self) -> str:
|
|
75
|
+
"""Non-empty ``identifier`` from the root data entity (``@graph`` → ``@id`` ``./``)."""
|
|
76
|
+
return _extract_identifier(self._root)
|
|
77
|
+
|
|
78
|
+
@cached_property
|
|
79
|
+
def name(self) -> str | None:
|
|
80
|
+
"""Optional RO-Crate ``name`` from the root data entity."""
|
|
81
|
+
return _extract_optional_text(self._root, "name")
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def description(self) -> str | None:
|
|
85
|
+
"""Optional RO-Crate ``description`` from the root data entity."""
|
|
86
|
+
return _extract_optional_text(self._root, "description")
|
|
@@ -5,6 +5,7 @@ from typing import Annotated
|
|
|
5
5
|
from pydantic import BaseModel, Field
|
|
6
6
|
|
|
7
7
|
from ..common.models import ApiResponse, ArcOperationResult, TaskStatus
|
|
8
|
+
from ..common.rocrate import RoCratePayload
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class HealthResponse(BaseModel):
|
|
@@ -18,7 +19,7 @@ class CreateOrUpdateArcRequest(BaseModel):
|
|
|
18
19
|
"""Request model for creating or updating a single ARC."""
|
|
19
20
|
|
|
20
21
|
rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
|
|
21
|
-
arc: Annotated[
|
|
22
|
+
arc: Annotated[RoCratePayload, Field(description="ARC definition in RO-Crate JSON format")]
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class CreateOrUpdateArcResponse(ApiResponse):
|
|
@@ -6,13 +6,14 @@ from typing import Annotated
|
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
from ..common.models import ApiResponse, ArcLifecycleStatus, ArcStatus, HarvestStatus
|
|
9
|
+
from ..common.rocrate import RoCratePayload
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class CreateArcRequest(BaseModel):
|
|
12
13
|
"""Request model for creating or updating a single ARC."""
|
|
13
14
|
|
|
14
15
|
rdi: Annotated[str, Field(description="Research Data Infrastructure identifier")]
|
|
15
|
-
arc: Annotated[
|
|
16
|
+
arc: Annotated[RoCratePayload, Field(description="ARC definition in RO-Crate JSON format")]
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class BaseStatusResponse(BaseModel):
|
|
@@ -49,7 +50,7 @@ class SubmitHarvestArcRequest(BaseModel):
|
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
52
|
arc: Annotated[
|
|
52
|
-
|
|
53
|
+
RoCratePayload,
|
|
53
54
|
Field(
|
|
54
55
|
description=(
|
|
55
56
|
"ARC definition in RO-Crate JSON format. "
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Unit tests for RO-Crate payload validation."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from middleware.shared.api_models.common.rocrate import RoCratePayload
|
|
7
|
+
|
|
8
|
+
_MINIMAL_ROCRATE = {
|
|
9
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
10
|
+
"@graph": [{"@id": "./", "identifier": "AthalianaColdStressSugar"}],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_rocrate_payload_accepts_wire_format_only() -> None:
|
|
15
|
+
"""RoCratePayload mirrors top-level JSON-LD keys only."""
|
|
16
|
+
payload = RoCratePayload.model_validate(_MINIMAL_ROCRATE)
|
|
17
|
+
dumped = payload.model_dump(by_alias=True)
|
|
18
|
+
assert "@context" in dumped
|
|
19
|
+
assert "@graph" in dumped
|
|
20
|
+
assert "identifier" not in dumped
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_rocrate_payload_identifier() -> None:
|
|
24
|
+
"""Validated identifier is read from the root data entity path."""
|
|
25
|
+
payload = RoCratePayload.model_validate(_MINIMAL_ROCRATE)
|
|
26
|
+
assert payload.identifier == "AthalianaColdStressSugar"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_rocrate_payload_name() -> None:
|
|
30
|
+
"""Optional RO-Crate ``name`` is extracted from the root data entity."""
|
|
31
|
+
arc = {
|
|
32
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
33
|
+
"@graph": [
|
|
34
|
+
{
|
|
35
|
+
"@id": "./",
|
|
36
|
+
"identifier": "AthalianaColdStressSugar",
|
|
37
|
+
"name": "Arabidopsis thaliana cold acclimation",
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
assert RoCratePayload.model_validate(arc).name == "Arabidopsis thaliana cold acclimation"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_rocrate_payload_missing_name_is_none() -> None:
|
|
45
|
+
"""Missing RO-Crate ``name`` is represented as None."""
|
|
46
|
+
assert RoCratePayload.model_validate(_MINIMAL_ROCRATE).name is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_rocrate_payload_description() -> None:
|
|
50
|
+
"""Optional RO-Crate ``description`` is extracted from the root data entity."""
|
|
51
|
+
arc = {
|
|
52
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
53
|
+
"@graph": [
|
|
54
|
+
{
|
|
55
|
+
"@id": "./",
|
|
56
|
+
"identifier": "dataset-1",
|
|
57
|
+
"description": "Cold stress experiment",
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
assert RoCratePayload.model_validate(arc).description == "Cold stress experiment"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_rocrate_payload_preserves_extra_root_fields() -> None:
|
|
65
|
+
"""Root entity fields beyond the API contract remain in ``@graph`` unchanged."""
|
|
66
|
+
arc = {
|
|
67
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
68
|
+
"@graph": [
|
|
69
|
+
{
|
|
70
|
+
"@id": "./",
|
|
71
|
+
"@type": "Dataset",
|
|
72
|
+
"additionalType": "Investigation",
|
|
73
|
+
"identifier": "dataset-1",
|
|
74
|
+
"name": "Example study",
|
|
75
|
+
"license": {"@id": "#LICENSE"},
|
|
76
|
+
"datePublished": "2025-12-09T13:41:46.875",
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
payload = RoCratePayload.model_validate(arc)
|
|
81
|
+
root = payload.model_dump(by_alias=True)["@graph"][0]
|
|
82
|
+
assert root["@type"] == "Dataset"
|
|
83
|
+
assert root["additionalType"] == "Investigation"
|
|
84
|
+
assert root["license"] == {"@id": "#LICENSE"}
|
|
85
|
+
assert root["datePublished"] == "2025-12-09T13:41:46.875"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_rocrate_payload_rejects_extra_top_level_fields() -> None:
|
|
89
|
+
"""RO-Crate metadata documents must not contain keys beyond @context and @graph."""
|
|
90
|
+
arc = {**_MINIMAL_ROCRATE, "unexpected": "field"}
|
|
91
|
+
with pytest.raises(ValidationError):
|
|
92
|
+
RoCratePayload.model_validate(arc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.parametrize(
|
|
96
|
+
"arc",
|
|
97
|
+
[
|
|
98
|
+
{"@context": "https://w3id.org/ro/crate/1.1/context"},
|
|
99
|
+
{
|
|
100
|
+
"@graph": [
|
|
101
|
+
{"@id": "./", "identifier": "ARC-006"},
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
106
|
+
"@graph": [{"@id": "ro-crate-metadata.json", "@type": "CreativeWork"}],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
110
|
+
"@graph": [{"@id": "./"}],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"@context": "https://w3id.org/ro/crate/1.1/context",
|
|
114
|
+
"@graph": [{"@id": "./", "identifier": ""}],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
def test_rocrate_payload_rejects_invalid_structure(arc: dict[str, object]) -> None:
|
|
119
|
+
"""Reject RO-Crate payloads that violate the API contract."""
|
|
120
|
+
with pytest.raises(ValidationError):
|
|
121
|
+
RoCratePayload.model_validate(arc)
|
|
File without changes
|
{fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/pyproject.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|