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.
Files changed (28) hide show
  1. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/.gitignore +3 -0
  2. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/PKG-INFO +1 -1
  3. fairagro_middleware_shared-9.1.1.dev14/src/middleware/shared/api_models/common/rocrate.py +86 -0
  4. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v2/models.py +2 -1
  5. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v3/models.py +3 -2
  6. fairagro_middleware_shared-9.1.1.dev14/tests/unit/test_rocrate.py +121 -0
  7. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/README.md +0 -0
  8. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/pyproject.toml +0 -0
  9. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/__init__.py +0 -0
  10. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/__init__.py +0 -0
  11. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/common/__init__.py +0 -0
  12. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/common/models.py +0 -0
  13. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/py.typed +0 -0
  14. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v1/__init__.py +0 -0
  15. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v1/models.py +0 -0
  16. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v2/__init__.py +0 -0
  17. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/api_models/v3/__init__.py +0 -0
  18. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/__init__.py +0 -0
  19. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/config_base.py +0 -0
  20. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/config_wrapper.py +0 -0
  21. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/config/logging.py +0 -0
  22. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/py.typed +0 -0
  23. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/src/middleware/shared/tracing.py +0 -0
  24. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_api_models.py +0 -0
  25. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_config_base.py +0 -0
  26. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_config_wrapper.py +0 -0
  27. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_logging.py +0 -0
  28. {fairagro_middleware_shared-9.0.1.dev13 → fairagro_middleware_shared-9.1.1.dev14}/tests/unit/test_tracing.py +0 -0
@@ -227,3 +227,6 @@ helmchart/**/client_ext.conf
227
227
  .cache_ggshield
228
228
 
229
229
  docker/Dockerfile.api.bak
230
+ # Docker DinD: ignore runtime state in docker-config, keep minimal config.json tracked
231
+ .devcontainer/docker-config/*
232
+ !.devcontainer/docker-config/config.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairagro-middleware-shared
3
- Version: 9.0.1.dev13
3
+ Version: 9.1.1.dev14
4
4
  Summary: The FAIRagro advanced middleware shared components
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opentelemetry-api>=1.26.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[dict, Field(description="ARC definition in RO-Crate JSON format")]
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[dict, Field(description="ARC definition in RO-Crate JSON format")]
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
- dict,
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)