fairagro-middleware-api-client 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 (19) hide show
  1. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/.gitignore +3 -0
  2. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/PKG-INFO +1 -1
  3. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/src/middleware/api_client/api_client.py +18 -20
  4. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/unit/test_client.py +46 -19
  5. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/README.md +0 -0
  6. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/example_client_config.yaml +0 -0
  7. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/pyproject.toml +0 -0
  8. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/spec/harvest-client/design.md +0 -0
  9. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/spec/harvest-client/spec.md +0 -0
  10. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/src/middleware/api_client/__init__.py +0 -0
  11. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/src/middleware/api_client/config.py +0 -0
  12. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/src/middleware/api_client/models.py +0 -0
  13. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/src/middleware/api_client/py.typed +0 -0
  14. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/conftest.py +0 -0
  15. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/integration/conftest.py +0 -0
  16. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/integration/test_create_arcs.py +0 -0
  17. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/unit/test_api_client_config.py +0 -0
  18. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/unit/test_client_config.py +0 -0
  19. {fairagro_middleware_api_client-9.0.1.dev13 → fairagro_middleware_api_client-9.1.1.dev14}/tests/unit/test_retry_logic.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-api-client
3
- Version: 9.0.1.dev13
3
+ Version: 9.1.1.dev14
4
4
  Summary: The FAIRagro advanced middleware API client
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.1
@@ -15,6 +15,7 @@ import httpx
15
15
  from pydantic import BaseModel, ValidationError
16
16
 
17
17
  from middleware.shared.api_models.common.models import HarvestStatus as SharedHarvestStatus
18
+ from middleware.shared.api_models.common.rocrate import RoCratePayload
18
19
  from middleware.shared.api_models.v3.models import (
19
20
  CreateArcRequest,
20
21
  CreateHarvestRequest,
@@ -295,7 +296,7 @@ class ApiClient:
295
296
  seen_identifiers: set[str] = set()
296
297
 
297
298
  async def submit_one(arc_item: dict[str, Any]) -> None:
298
- request = SubmitHarvestArcRequest(arc=arc_item)
299
+ request = SubmitHarvestArcRequest(arc=self._validate_rocrate(arc_item))
299
300
  await self._post(f"v3/harvests/{harvest_id}/arcs", request)
300
301
 
301
302
  async for arc in arcs:
@@ -305,7 +306,7 @@ class ApiClient:
305
306
  if identifier in seen_identifiers:
306
307
  logger.error(
307
308
  "Duplicate ARC identifier '%s' in harvest %s — "
308
- "two ARCs share the same identifier (client-side data error).",
309
+ "several ARCs share the same identifier (client-side data error).",
309
310
  identifier,
310
311
  harvest_id,
311
312
  )
@@ -504,23 +505,20 @@ class ApiClient:
504
505
  # ------------------------------------------------------------------
505
506
 
506
507
  @staticmethod
507
- def _extract_identifier_from_rocrate(arc_content: dict[str, Any]) -> str | None:
508
- """Extract the RO-Crate identifier from a serialized ARC dict.
508
+ def _validate_rocrate(arc_content: dict[str, Any]) -> RoCratePayload:
509
+ """Validate a RO-Crate dict before sending it to the API."""
510
+ try:
511
+ return RoCratePayload.model_validate(arc_content)
512
+ except ValidationError as e:
513
+ raise ApiClientError(f"Invalid RO-Crate JSON: {e}") from e
509
514
 
510
- Looks for the Root Data Entity (``@id == "./"``), then returns its
511
- ``identifier`` field. Returns ``None`` when the field is absent or
512
- the dict does not follow the RO-Crate structure validation is left
513
- to the server.
514
- """
515
- graph = arc_content.get("@graph")
516
- if isinstance(graph, list):
517
- for item in graph:
518
- if isinstance(item, dict) and item.get("@id") == "./":
519
- identifier = item.get("identifier")
520
- if isinstance(identifier, list):
521
- identifier = identifier[0] if identifier else None
522
- return str(identifier) if identifier else None
523
- return None
515
+ @staticmethod
516
+ def _extract_identifier_from_rocrate(arc_content: dict[str, Any]) -> str | None:
517
+ """Return the RO-Crate identifier when the payload is structurally valid."""
518
+ try:
519
+ return RoCratePayload.model_validate(arc_content).identifier
520
+ except ValidationError:
521
+ return None
524
522
 
525
523
  @classmethod
526
524
  def _serialize_arc(cls, arc: "ARC | dict[str, Any] | str") -> dict[str, Any]:
@@ -575,7 +573,7 @@ class ApiClient:
575
573
  """
576
574
  logger.info("Creating/updating ARC for RDI: %s", rdi)
577
575
  serialized = self._serialize_arc(arc)
578
- request = CreateArcRequest(rdi=rdi, arc=serialized)
576
+ request = CreateArcRequest(rdi=rdi, arc=self._validate_rocrate(serialized))
579
577
  data = await self._post("v3/arcs", request)
580
578
  return self._parse_arc_response(data)
581
579
 
@@ -710,7 +708,7 @@ class ApiClient:
710
708
  :class:`ArcResult` with the result of the operation.
711
709
  """
712
710
  serialized = self._serialize_arc(arc)
713
- request = SubmitHarvestArcRequest(arc=serialized)
711
+ request = SubmitHarvestArcRequest(arc=self._validate_rocrate(serialized))
714
712
  data = await self._post(f"v3/harvests/{harvest_id}/arcs", request)
715
713
  return self._parse_arc_response(data)
716
714
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
  import http
5
+ import json
5
6
  import ssl
6
7
  from collections.abc import AsyncGenerator
7
8
  from pathlib import Path
@@ -40,6 +41,15 @@ _ARC_RESPONSE = {
40
41
  "events": [],
41
42
  }
42
43
 
44
+
45
+ def _rocrate_dict(identifier: str = "mock-arc") -> dict[str, Any]:
46
+ """Minimal valid RO-Crate payload for client tests."""
47
+ return {
48
+ "@context": "https://w3id.org/ro/crate/1.1/context",
49
+ "@graph": [{"@id": "./", "identifier": identifier}],
50
+ }
51
+
52
+
43
53
  _HARVEST_RESPONSE: dict[str, str | None | dict] = {
44
54
  "client_id": "test-client",
45
55
  "message": "Harvest created",
@@ -229,7 +239,7 @@ async def test_create_or_update_arc_with_dict(client_config: Config) -> None:
229
239
  return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
230
240
  )
231
241
  async with ApiClient(client_config) as client:
232
- response = await client.create_or_update_arc(rdi="test-rdi", arc={"id": "mock-arc"})
242
+ response = await client.create_or_update_arc(rdi="test-rdi", arc=_rocrate_dict())
233
243
  assert isinstance(response, ArcResult)
234
244
 
235
245
 
@@ -241,7 +251,7 @@ async def test_create_or_update_arc_with_json_string(client_config: Config) -> N
241
251
  return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
242
252
  )
243
253
  async with ApiClient(client_config) as client:
244
- response = await client.create_or_update_arc(rdi="test-rdi", arc='{"id": "mock-arc"}')
254
+ response = await client.create_or_update_arc(rdi="test-rdi", arc=json.dumps(_rocrate_dict()))
245
255
  assert route.called
246
256
  assert isinstance(response, ArcResult)
247
257
  assert response.arc_id == "arc-123"
@@ -252,7 +262,7 @@ async def test_create_or_update_arc_with_invalid_json_string(client_config: Conf
252
262
  """Test create_or_update_arc with an invalid JSON string."""
253
263
  async with ApiClient(client_config) as client:
254
264
  with pytest.raises(ApiClientError, match="Invalid JSON string provided for ARC"):
255
- await client.create_or_update_arc(rdi="test-rdi", arc='{"id": "mock-arc"')
265
+ await client.create_or_update_arc(rdi="test-rdi", arc='{"@context":')
256
266
 
257
267
 
258
268
  @pytest.mark.asyncio
@@ -290,6 +300,14 @@ async def test_create_or_update_arc_invalid_response(client_config: Config) -> N
290
300
  )
291
301
  async with ApiClient(client_config) as client:
292
302
  with pytest.raises(ApiClientError, match="Invalid ARC response"):
303
+ await client.create_or_update_arc(rdi="test-rdi", arc=_rocrate_dict("mock"))
304
+
305
+
306
+ @pytest.mark.asyncio
307
+ async def test_create_or_update_arc_invalid_rocrate(client_config: Config) -> None:
308
+ """Test create_or_update_arc rejects structurally invalid RO-Crate JSON."""
309
+ async with ApiClient(client_config) as client:
310
+ with pytest.raises(ApiClientError, match="Invalid RO-Crate JSON"):
293
311
  await client.create_or_update_arc(rdi="test-rdi", arc={"id": "mock"})
294
312
 
295
313
 
@@ -301,7 +319,7 @@ async def test_create_or_update_arc_sends_correct_headers(client_config: Config)
301
319
  return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
302
320
  )
303
321
  async with ApiClient(client_config) as client:
304
- await client.create_or_update_arc(rdi="test", arc={"id": "mock-arc"})
322
+ await client.create_or_update_arc(rdi="test", arc=_rocrate_dict())
305
323
 
306
324
  assert route.called
307
325
  req = route.calls.last.request
@@ -499,7 +517,7 @@ async def test_submit_arc_in_harvest(client_config: Config) -> None:
499
517
  return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
500
518
  )
501
519
  async with ApiClient(client_config) as client:
502
- response = await client.submit_arc_in_harvest("harvest-456", arc={"id": "mock-arc"})
520
+ response = await client.submit_arc_in_harvest("harvest-456", arc=_rocrate_dict())
503
521
  assert isinstance(response, ArcResult)
504
522
  assert response.arc_id == "arc-123"
505
523
 
@@ -513,6 +531,14 @@ async def test_submit_arc_in_harvest_invalid_response(client_config: Config) ->
513
531
  )
514
532
  async with ApiClient(client_config) as client:
515
533
  with pytest.raises(ApiClientError, match="Invalid ARC response"):
534
+ await client.submit_arc_in_harvest("harvest-456", arc=_rocrate_dict("mock"))
535
+
536
+
537
+ @pytest.mark.asyncio
538
+ async def test_submit_arc_in_harvest_invalid_rocrate(client_config: Config) -> None:
539
+ """Test submit_arc_in_harvest rejects structurally invalid RO-Crate JSON."""
540
+ async with ApiClient(client_config) as client:
541
+ with pytest.raises(ApiClientError, match="Invalid RO-Crate JSON"):
516
542
  await client.submit_arc_in_harvest("harvest-456", arc={"id": "mock"})
517
543
 
518
544
 
@@ -524,7 +550,7 @@ async def test_submit_arc_in_harvest_with_json_string(client_config: Config) ->
524
550
  return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
525
551
  )
526
552
  async with ApiClient(client_config) as client:
527
- response = await client.submit_arc_in_harvest("harvest-456", arc='{"id": "mock-arc"}')
553
+ response = await client.submit_arc_in_harvest("harvest-456", arc=json.dumps(_rocrate_dict()))
528
554
  assert route.called
529
555
  assert isinstance(response, ArcResult)
530
556
  assert response.arc_id == "arc-123"
@@ -535,7 +561,7 @@ async def test_submit_arc_in_harvest_with_invalid_json_string(client_config: Con
535
561
  """Test submit_arc_in_harvest with an invalid JSON string."""
536
562
  async with ApiClient(client_config) as client:
537
563
  with pytest.raises(ApiClientError, match="Invalid JSON string provided for ARC"):
538
- await client.submit_arc_in_harvest("harvest-456", arc='{"id": "mock-arc"')
564
+ await client.submit_arc_in_harvest("harvest-456", arc='{"@context":')
539
565
 
540
566
 
541
567
  # ---------------------------------------------------------------------------
@@ -564,7 +590,7 @@ async def test_harvest_arcs_success(client_config: Config) -> None:
564
590
  return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
565
591
  )
566
592
 
567
- arcs = _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"})
593
+ arcs = _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3"))
568
594
  async with ApiClient(client_config) as client:
569
595
  result = await client.harvest_arcs("test-rdi", arcs, expected_datasets=3)
570
596
 
@@ -589,7 +615,7 @@ async def test_harvest_arcs_success_with_parallelism(client_config: Config) -> N
589
615
  return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
590
616
  )
591
617
 
592
- arcs = _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"})
618
+ arcs = _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3"))
593
619
  async with ApiClient(client_config) as client:
594
620
  result = await client.harvest_arcs("test-rdi", arcs)
595
621
 
@@ -614,7 +640,7 @@ async def test_harvest_arcs_uses_config_default_concurrency(client_config: Confi
614
640
  return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
615
641
  )
616
642
 
617
- arcs = _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"})
643
+ arcs = _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3"))
618
644
  async with ApiClient(client_config) as client:
619
645
  result = await client.harvest_arcs("test-rdi", arcs)
620
646
 
@@ -664,7 +690,8 @@ async def test_harvest_arcs_continues_on_item_error(client_config: Config) -> No
664
690
  )
665
691
 
666
692
  async with ApiClient(client_config) as client:
667
- result = await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"}))
693
+ arcs = _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3"))
694
+ result = await client.harvest_arcs("test-rdi", arcs)
668
695
 
669
696
  assert isinstance(result, HarvestResult)
670
697
  assert route_submit.call_count == EXPECTED_ARC_UPLOADS
@@ -672,7 +699,7 @@ async def test_harvest_arcs_continues_on_item_error(client_config: Config) -> No
672
699
  assert not cancel_route.called
673
700
  assert len(result.errors) == 1
674
701
  assert result.errors[0].error_type == HarvestErrorType.SUBMISSION_FAILED
675
- assert result.errors[0].arc_id is None
702
+ assert result.errors[0].arc_id == "arc-1"
676
703
  assert "HTTP error 400" in result.errors[0].message
677
704
 
678
705
 
@@ -698,7 +725,7 @@ async def test_harvest_arcs_cancels_on_catastrophic_error(client_config: Config)
698
725
 
699
726
  async with ApiClient(client_config) as client:
700
727
  with pytest.raises(ApiClientError, match="HTTP error 409"):
701
- await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}))
728
+ await client.harvest_arcs("test-rdi", _arc_gen(_rocrate_dict("arc-1")))
702
729
 
703
730
  assert fail_route.called
704
731
 
@@ -760,8 +787,8 @@ async def test_harvest_arcs_with_json_string(client_config: Config) -> None:
760
787
  )
761
788
 
762
789
  arcs = _arc_gen(
763
- '{"id": "arc-1-string"}',
764
- {"id": "arc-2-dict"},
790
+ json.dumps(_rocrate_dict("arc-1-string")),
791
+ _rocrate_dict("arc-2-dict"),
765
792
  ARC.from_arc_investigation(ArcInvestigation.create(identifier="test", title="Test")),
766
793
  )
767
794
  async with ApiClient(client_config) as client:
@@ -807,7 +834,7 @@ async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_c
807
834
 
808
835
  async with ApiClient(client_config) as client:
809
836
  with pytest.raises(ApiClientError, match="HTTP error 409"):
810
- await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}))
837
+ await client.harvest_arcs("test-rdi", _arc_gen(_rocrate_dict("arc-1")))
811
838
 
812
839
 
813
840
  @pytest.mark.asyncio
@@ -855,7 +882,7 @@ async def test_harvest_arcs_all_exceptions_retrieved_when_multiple_tasks_catastr
855
882
  with pytest.raises(ApiClientError, match="HTTP error 409"):
856
883
  await client.harvest_arcs(
857
884
  "test-rdi",
858
- _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"}),
885
+ _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3")),
859
886
  )
860
887
 
861
888
  assert fail_route.called
@@ -892,7 +919,7 @@ async def test_harvest_arcs_500_is_submission_failed(client_config: Config) -> N
892
919
  async with ApiClient(client_config) as client:
893
920
  result = await client.harvest_arcs(
894
921
  "test-rdi",
895
- _arc_gen({"id": "arc-1"}, {"id": "arc-2"}, {"id": "arc-3"}),
922
+ _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2"), _rocrate_dict("arc-3")),
896
923
  )
897
924
 
898
925
  assert complete_route.called
@@ -929,7 +956,7 @@ async def test_harvest_arcs_502_is_submission_failed(client_config: Config) -> N
929
956
  )
930
957
 
931
958
  async with ApiClient(client_config) as client:
932
- result = await client.harvest_arcs("test-rdi", _arc_gen({"id": "arc-1"}, {"id": "arc-2"}))
959
+ result = await client.harvest_arcs("test-rdi", _arc_gen(_rocrate_dict("arc-1"), _rocrate_dict("arc-2")))
933
960
 
934
961
  assert complete_route.called
935
962
  assert not fail_route.called