oapi-profile-builder 3.4.1__tar.gz → 3.5.0__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 (20) hide show
  1. {oapi_profile_builder-3.4.1/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.5.0}/PKG-INFO +48 -1
  2. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/README.md +47 -0
  3. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/generate.py +290 -37
  5. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/models.py +305 -0
  6. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0/src/oapi_profile_builder.egg-info}/PKG-INFO +48 -1
  7. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/LICENSE +0 -0
  8. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/setup.cfg +0 -0
  9. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/__init__.py +0 -0
  10. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cite.py +0 -0
  11. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cite_features.py +0 -0
  12. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cli.py +0 -0
  13. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/compile.py +0 -0
  14. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cover.py +0 -0
  15. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.4.1
3
+ Version: 3.5.0
4
4
  Summary: Authoritative tooling for creating OGC API Service Profiles (EDR, Features)
5
5
  Author-email: Shane Mill <shane.mill@noaa.gov>
6
6
  License: Apache License
@@ -342,6 +342,13 @@ abstract_tests: []
342
342
  | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
343
343
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
344
344
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
345
+ | `collection_title_max_length` | int | no | When set, every collection title must be present and within this many characters (MetOcean `/req/core/collection_title`) |
346
+ | `require_license_link` | bool | no | When `true`, every collection must declare a `rel='license'` link (MetOcean `/req/core/collection_license`). Defaults to `false` |
347
+ | `license_link_type` | string | no | Expected media type of the `rel='license'` link when `require_license_link` is set. Defaults to `text/html`; set to null to accept any type |
348
+ | `required_data_queries` | list | no | Data-query types every collection must define (e.g. `[locations, area, radius]`) — enforces the mandatory data-query sets |
349
+ | `radius_within_units_required` | list | no | Unit tokens each radius query's `within_units` must contain (e.g. `[m]`) (MetOcean `/req/core/collection_radius_data_query`) |
350
+ | `locations_feature_required_properties` | list | no | Feature properties (e.g. `[name]`) marked required in the generated `/locations` GeoJSON response schema, in addition to the always-required string `id` |
351
+ | `conformance_class_requirements` | object | no | Map of conformance-class short name → constraints (`required_data_queries`, `radius_within_units_required`) applied only to collections that declare the class. Lets one profile document model several EDR Part 3 requirements classes (see below) |
345
352
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
346
353
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
347
354
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
@@ -381,6 +388,8 @@ Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model
381
388
  | `output_formats` | no | Format names this collection supports (e.g. `[CoverageJSON, GeoJSON]`) |
382
389
  | `data_queries` | no | EDR query types: `position`, `area`, `radius`, `cube`, `trajectory`, `corridor`, `locations`, `items`, `instances` |
383
390
  | `parameter_names` | no | Map of parameter id → Parameter object. All parameters must have `unit` and `observedProperty` |
391
+ | `conformance_classes` | no | Profile conformance-class short names this collection implements (e.g. `[core, insitu-observations]`). Selects which `conformance_class_requirements` are enforced; surfaced as `x-conformance-classes` in the OpenAPI |
392
+ | `parameter_schema` | no | Per-collection JSON Schema for parameter objects, overriding the profile-level `parameter_schema` for this collection only (precedence: collection → profile → default) |
384
393
 
385
394
  #### `data_queries` example
386
395
 
@@ -513,6 +522,38 @@ extent_requirements:
513
522
 
514
523
  ---
515
524
 
525
+ ### Multiple conformance classes in one profile
526
+
527
+ OGC API - EDR Part 3 profiles are modular: a single profile document can define several requirements/conformance classes, and different collections in the same service may implement different classes. The MetOcean EDR Profile is a good example — Core applies everywhere, but only in-situ collections must offer `locations`/`area`/`radius` while NWP collections must offer `position`/`cube`.
528
+
529
+ Model this with `conformance_class_requirements` (profile level) plus `conformance_classes` (per collection):
530
+
531
+ ```yaml
532
+ # Core-level rules apply to every collection:
533
+ collection_title_max_length: 50
534
+ require_license_link: true
535
+
536
+ # Class-scoped rules apply only where a collection opts in:
537
+ conformance_class_requirements:
538
+ insitu-observations:
539
+ required_data_queries: [locations, area, radius]
540
+ radius_within_units_required: [m]
541
+ nwp:
542
+ required_data_queries: [position, cube]
543
+
544
+ collections:
545
+ - id: insitu-observations
546
+ conformance_classes: [core, insitu-observations] # must have locations/area/radius
547
+ # ...
548
+ - id: nwp-forecast
549
+ conformance_classes: [core, nwp] # must have position/cube
550
+ # ...
551
+ ```
552
+
553
+ The flat `required_data_queries` / `radius_within_units_required` fields still apply to *every* collection (the Core-applies-to-all shorthand); class-scoped rules are additive. See `examples/metocean_profile.yaml` for a full worked profile spanning all four MetOcean classes.
554
+
555
+ ---
556
+
516
557
  ### `output_formats`
517
558
 
518
559
  Maps format names (referenced by `collections[].output_formats` and `data_queries.*.variables.output_formats`) to a media type and, optionally, a schema reference. The `schema_ref` flows into the generated OpenAPI response `content` for that format — use it to point a format at a specific schema (e.g. a DGIWG-published schema for GeoTIFF or CoverageJSON) instead of the tool's built-in default.
@@ -898,6 +939,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
898
939
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
899
940
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
900
941
  | `collection_id_pattern` enforced | All collection IDs must match if set |
942
+ | `collection_title_max_length` enforced | If set, every collection title must be present and within the limit |
943
+ | `require_license_link` enforced | If set, every collection must have a `rel='license'` link (of `license_link_type` when specified) |
944
+ | `required_data_queries` enforced | If set, every collection with `data_queries` must define each listed query type |
945
+ | `radius_within_units_required` enforced | If set, each radius query's `within_units` must contain every listed unit token |
946
+ | `conformance_class_requirements` enforced | Class-scoped `required_data_queries` / `radius_within_units_required` applied to collections whose `conformance_classes` include the class key |
947
+ | `conformance_classes` references known class | When `conformance_class_requirements` is set, a collection's `conformance_classes` may only use `core` or a defined class key (catches typos) |
901
948
  | Abstract test IDs match requirements | `requirement_id` must reference an existing requirement |
902
949
  | Requirement IDs | Must match `^[a-z0-9][a-z0-9\-]*$`, no trailing hyphen |
903
950
 
@@ -295,6 +295,13 @@ abstract_tests: []
295
295
  | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
296
296
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
297
297
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
298
+ | `collection_title_max_length` | int | no | When set, every collection title must be present and within this many characters (MetOcean `/req/core/collection_title`) |
299
+ | `require_license_link` | bool | no | When `true`, every collection must declare a `rel='license'` link (MetOcean `/req/core/collection_license`). Defaults to `false` |
300
+ | `license_link_type` | string | no | Expected media type of the `rel='license'` link when `require_license_link` is set. Defaults to `text/html`; set to null to accept any type |
301
+ | `required_data_queries` | list | no | Data-query types every collection must define (e.g. `[locations, area, radius]`) — enforces the mandatory data-query sets |
302
+ | `radius_within_units_required` | list | no | Unit tokens each radius query's `within_units` must contain (e.g. `[m]`) (MetOcean `/req/core/collection_radius_data_query`) |
303
+ | `locations_feature_required_properties` | list | no | Feature properties (e.g. `[name]`) marked required in the generated `/locations` GeoJSON response schema, in addition to the always-required string `id` |
304
+ | `conformance_class_requirements` | object | no | Map of conformance-class short name → constraints (`required_data_queries`, `radius_within_units_required`) applied only to collections that declare the class. Lets one profile document model several EDR Part 3 requirements classes (see below) |
298
305
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
299
306
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
300
307
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
@@ -334,6 +341,8 @@ Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model
334
341
  | `output_formats` | no | Format names this collection supports (e.g. `[CoverageJSON, GeoJSON]`) |
335
342
  | `data_queries` | no | EDR query types: `position`, `area`, `radius`, `cube`, `trajectory`, `corridor`, `locations`, `items`, `instances` |
336
343
  | `parameter_names` | no | Map of parameter id → Parameter object. All parameters must have `unit` and `observedProperty` |
344
+ | `conformance_classes` | no | Profile conformance-class short names this collection implements (e.g. `[core, insitu-observations]`). Selects which `conformance_class_requirements` are enforced; surfaced as `x-conformance-classes` in the OpenAPI |
345
+ | `parameter_schema` | no | Per-collection JSON Schema for parameter objects, overriding the profile-level `parameter_schema` for this collection only (precedence: collection → profile → default) |
337
346
 
338
347
  #### `data_queries` example
339
348
 
@@ -466,6 +475,38 @@ extent_requirements:
466
475
 
467
476
  ---
468
477
 
478
+ ### Multiple conformance classes in one profile
479
+
480
+ OGC API - EDR Part 3 profiles are modular: a single profile document can define several requirements/conformance classes, and different collections in the same service may implement different classes. The MetOcean EDR Profile is a good example — Core applies everywhere, but only in-situ collections must offer `locations`/`area`/`radius` while NWP collections must offer `position`/`cube`.
481
+
482
+ Model this with `conformance_class_requirements` (profile level) plus `conformance_classes` (per collection):
483
+
484
+ ```yaml
485
+ # Core-level rules apply to every collection:
486
+ collection_title_max_length: 50
487
+ require_license_link: true
488
+
489
+ # Class-scoped rules apply only where a collection opts in:
490
+ conformance_class_requirements:
491
+ insitu-observations:
492
+ required_data_queries: [locations, area, radius]
493
+ radius_within_units_required: [m]
494
+ nwp:
495
+ required_data_queries: [position, cube]
496
+
497
+ collections:
498
+ - id: insitu-observations
499
+ conformance_classes: [core, insitu-observations] # must have locations/area/radius
500
+ # ...
501
+ - id: nwp-forecast
502
+ conformance_classes: [core, nwp] # must have position/cube
503
+ # ...
504
+ ```
505
+
506
+ The flat `required_data_queries` / `radius_within_units_required` fields still apply to *every* collection (the Core-applies-to-all shorthand); class-scoped rules are additive. See `examples/metocean_profile.yaml` for a full worked profile spanning all four MetOcean classes.
507
+
508
+ ---
509
+
469
510
  ### `output_formats`
470
511
 
471
512
  Maps format names (referenced by `collections[].output_formats` and `data_queries.*.variables.output_formats`) to a media type and, optionally, a schema reference. The `schema_ref` flows into the generated OpenAPI response `content` for that format — use it to point a format at a specific schema (e.g. a DGIWG-published schema for GeoTIFF or CoverageJSON) instead of the tool's built-in default.
@@ -851,6 +892,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
851
892
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
852
893
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
853
894
  | `collection_id_pattern` enforced | All collection IDs must match if set |
895
+ | `collection_title_max_length` enforced | If set, every collection title must be present and within the limit |
896
+ | `require_license_link` enforced | If set, every collection must have a `rel='license'` link (of `license_link_type` when specified) |
897
+ | `required_data_queries` enforced | If set, every collection with `data_queries` must define each listed query type |
898
+ | `radius_within_units_required` enforced | If set, each radius query's `within_units` must contain every listed unit token |
899
+ | `conformance_class_requirements` enforced | Class-scoped `required_data_queries` / `radius_within_units_required` applied to collections whose `conformance_classes` include the class key |
900
+ | `conformance_classes` references known class | When `conformance_class_requirements` is set, a collection's `conformance_classes` may only use `core` or a defined class key (catches typos) |
854
901
  | Abstract test IDs match requirements | `requirement_id` must reference an existing requirement |
855
902
  | Requirement IDs | Must match `^[a-z0-9][a-z0-9\-]*$`, no trailing hyphen |
856
903
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.4.1"
7
+ version = "3.5.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -70,10 +70,46 @@ _DATETIME = {"$ref": f"{_FEATURES}#/components/parameters/datetime"}
70
70
  _PARAM_NAME = {"$ref": f"{_EDR}/parameters/parameter-name.yaml"}
71
71
  _Z = {"$ref": f"{_EDR}/parameters/z.yaml"}
72
72
 
73
- _ERR_SCHEMA = {"type": "object", "properties": {"code": {"type": "string"}, "description": {"type": "string"}}}
74
- _ERR_400 = {"description": "Invalid or missing parameter", "content": {"application/json": {"schema": _ERR_SCHEMA}}}
75
- _ERR_404 = {"description": "Not found", "content": {"application/json": {"schema": _ERR_SCHEMA}}}
76
- _ERR_500 = {"description": "Server error", "content": {"application/json": {"schema": _ERR_SCHEMA}}}
73
+ # Error responses follow RFC 9457 (Problem Details for HTTP APIs), served as
74
+ # application/problem+json, per OGC API - EDR Part 3 error-handling requirement
75
+ # (and the MetOcean EDR Profile /req/core/error_handling). ``type`` and ``title``
76
+ # are the two members RFC 9457 always defines; the rest are optional but modelled
77
+ # so downstream tooling (Swagger UI, schemathesis, CITE) sees the full shape.
78
+ _PROBLEM_MEDIA_TYPE = "application/problem+json"
79
+ _PROBLEM_SCHEMA = {
80
+ "type": "object",
81
+ "required": ["type", "title"],
82
+ "properties": {
83
+ "type": {
84
+ "type": "string",
85
+ "format": "uri-reference",
86
+ "default": "about:blank",
87
+ "description": "A URI reference that identifies the problem type (RFC 9457).",
88
+ },
89
+ "title": {
90
+ "type": "string",
91
+ "description": "A short, human-readable summary of the problem type.",
92
+ },
93
+ "status": {
94
+ "type": "integer",
95
+ "description": "The HTTP status code generated by the origin server.",
96
+ },
97
+ "detail": {
98
+ "type": "string",
99
+ "description": "A human-readable explanation specific to this occurrence of the problem.",
100
+ },
101
+ "instance": {
102
+ "type": "string",
103
+ "format": "uri-reference",
104
+ "description": "A URI reference that identifies the specific occurrence of the problem.",
105
+ },
106
+ },
107
+ }
108
+ # Backwards-compatible alias; the error schema is now RFC 9457 Problem Details.
109
+ _ERR_SCHEMA = _PROBLEM_SCHEMA
110
+ _ERR_400 = {"description": "Invalid or missing parameter", "content": {_PROBLEM_MEDIA_TYPE: {"schema": _PROBLEM_SCHEMA}}}
111
+ _ERR_404 = {"description": "Not found", "content": {_PROBLEM_MEDIA_TYPE: {"schema": _PROBLEM_SCHEMA}}}
112
+ _ERR_500 = {"description": "Server error", "content": {_PROBLEM_MEDIA_TYPE: {"schema": _PROBLEM_SCHEMA}}}
77
113
  _ERR_DEFAULT = {"$ref": "#/components/responses/default"}
78
114
 
79
115
  _LINK_SCHEMA = {
@@ -164,6 +200,52 @@ _R200_FEATURES = {
164
200
  },
165
201
  }
166
202
 
203
+ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
204
+ """Build the 200 response for a /locations query.
205
+
206
+ Per the EDR locations-response requirement (and the MetOcean profile's
207
+ /req/core/locations_query_response_format), the response is a GeoJSON
208
+ FeatureCollection where every feature carries a string ``id`` and a
209
+ ``properties`` object. ``properties.name`` is always documented; any
210
+ properties named in ``profile.locations_feature_required_properties`` are
211
+ marked required so the generated OpenAPI enforces them.
212
+ """
213
+ required_props = list(getattr(profile, "locations_feature_required_properties", None) or [])
214
+ properties_schema: dict = {
215
+ "type": "object",
216
+ "properties": {
217
+ "name": {"type": "string", "description": "Human-readable name of the location"},
218
+ },
219
+ }
220
+ if required_props:
221
+ properties_schema["required"] = required_props
222
+ feature_schema = {
223
+ "type": "object",
224
+ "required": ["type", "id", "properties"],
225
+ "properties": {
226
+ "type": {"type": "string", "const": "Feature"},
227
+ "id": {"type": "string", "description": "Location identifier usable at /locations/{locId}"},
228
+ "geometry": {"type": "object", "description": "GeoJSON geometry of the location"},
229
+ "properties": properties_schema,
230
+ },
231
+ }
232
+ return {
233
+ "description": "GeoJSON FeatureCollection of locations",
234
+ "content": {
235
+ "application/geo+json": {
236
+ "schema": {
237
+ "type": "object",
238
+ "required": ["type", "features"],
239
+ "properties": {
240
+ "type": {"type": "string", "const": "FeatureCollection"},
241
+ "features": {"type": "array", "items": feature_schema},
242
+ },
243
+ }
244
+ }
245
+ },
246
+ }
247
+
248
+
167
249
  def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> dict:
168
250
  """Build a coverage response dict reflecting the collection's actual output formats."""
169
251
  # Collect media_type → schema_ref from profile-level output_formats
@@ -212,7 +294,15 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
212
294
  "schema": {"$ref": f"{_EDR}/schemas/coverageJSON.yaml"}
213
295
  }
214
296
 
215
- return {"200": {"description": "Coverage data response", "content": content}}
297
+ # Per the EDR error-handling requirement, a query whose spatio-temporal
298
+ # selection falls inside the collection extent but matches no data returns
299
+ # HTTP 204 (No Content); 4xx/5xx use RFC 9457 problem+json.
300
+ return {
301
+ "200": {"description": "Coverage data response", "content": content},
302
+ "204": {"description": "No data available for the requested query (empty result inside the extent)"},
303
+ "400": _ERR_400,
304
+ "500": _ERR_500,
305
+ }
216
306
 
217
307
  # ---------------------------------------------------------------------------
218
308
  # POST request body schemas — one per EDR query type.
@@ -448,9 +538,13 @@ def _collection_response_schema(coll: Collection,
448
538
  _apply(vrs_schema, er.extent_vrs)
449
539
  _apply(supported_crs_schema, er.supported_crs)
450
540
 
451
- # Build parameter_names schema — use profile-supplied schema if present,
452
- # otherwise fall back to the default EDR Parameter schema.
453
- if profile and profile.parameter_schema:
541
+ # Build parameter_names schema. Precedence: per-collection parameter_schema
542
+ # override → profile-level parameter_schema → default EDR Parameter schema.
543
+ coll_param_schema = getattr(coll, "parameter_schema", None)
544
+ if coll_param_schema:
545
+ import copy
546
+ param_item_schema = copy.deepcopy(coll_param_schema)
547
+ elif profile and profile.parameter_schema:
454
548
  # Deep-copy so we don't mutate the profile model
455
549
  import copy
456
550
  param_item_schema = copy.deepcopy(profile.parameter_schema)
@@ -809,7 +903,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
809
903
  # Build coverage response reflecting this collection's actual output formats
810
904
  cov_resp = _coverage_response(coll, profile)
811
905
 
812
- paths[base] = {"get": {
906
+ describe_op = {
813
907
  "summary": f"Get {coll.title or coll.id} metadata",
814
908
  "description": desc,
815
909
  "operationId": _operation_id("describe", coll.id, "Collection"),
@@ -819,7 +913,12 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
819
913
  "200": coll_schema,
820
914
  "400": _ERR_400, "404": _ERR_404, "500": _ERR_500,
821
915
  },
822
- }}
916
+ }
917
+ # Surface the profile conformance classes this collection implements so the
918
+ # OpenAPI records which EDR Part 3 requirements classes apply to it.
919
+ if getattr(coll, "conformance_classes", None):
920
+ describe_op["x-conformance-classes"] = list(coll.conformance_classes)
921
+ paths[base] = {"get": describe_op}
823
922
 
824
923
  # Always generate Features paths (items, queryables, schema)
825
924
  items_params = _with_paging_limit([_F, _LANG], profile)
@@ -925,20 +1024,22 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
925
1024
  paths[f"{base}/items"] = path_ops
926
1025
 
927
1026
  elif qt == "locations":
1027
+ loc_resp = _locations_response_schema(profile)
1028
+ locations_responses = {
1029
+ "200": loc_resp,
1030
+ "400": _ERR_400, "500": _ERR_500,
1031
+ }
928
1032
  get_locations_op = {
929
1033
  "summary": f"Get pre-defined locations of {coll.id}",
930
1034
  "description": desc,
931
1035
  "operationId": _operation_id("getLocations", coll.id),
932
1036
  "tags": [tag],
933
1037
  "parameters": params,
934
- "responses": {
935
- "200": _R200_FEATURES,
936
- "400": _ERR_400, "500": _ERR_500,
937
- },
1038
+ "responses": locations_responses,
938
1039
  }
939
1040
  path_ops = {"get": get_locations_op}
940
1041
  if include_post:
941
- path_ops["post"] = _post_operation(get_locations_op, "locations", coll.id, {"200": _R200_FEATURES, "400": _ERR_400, "500": _ERR_500})
1042
+ path_ops["post"] = _post_operation(get_locations_op, "locations", coll.id, locations_responses)
942
1043
  paths[f"{base}/locations"] = path_ops
943
1044
 
944
1045
  get_location_op = {
@@ -1236,7 +1337,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1236
1337
  },
1237
1338
  },
1238
1339
  "responses": {
1239
- "default": {"description": "Unexpected error", "content": {"application/json": {"schema": {"type": "object"}}}},
1340
+ "default": {"description": "Unexpected error", "content": {_PROBLEM_MEDIA_TYPE: {"schema": _PROBLEM_SCHEMA}}},
1240
1341
  },
1241
1342
  },
1242
1343
  }
@@ -1404,6 +1505,109 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1404
1505
  return "\n".join(lines) + "\n"
1405
1506
 
1406
1507
 
1508
+ # ---------------------------------------------------------------------------
1509
+ # Multi-class requirements/conformance grouping
1510
+ #
1511
+ # When one or more requirements declare a `conformance_class`, the generated
1512
+ # AsciiDoc/PDF is organised into one requirements class (and matching
1513
+ # conformance class) per key, mirroring how a multi-class profile document
1514
+ # (e.g. the MetOcean profile) is structured. When no requirement declares a
1515
+ # class, `_requirement_class_groups` returns None and the single-class code
1516
+ # path is used unchanged (backward compatible).
1517
+ # ---------------------------------------------------------------------------
1518
+
1519
+ def _requirement_class_groups(profile: ServiceProfile):
1520
+ """Return an ordered dict {class_key: {"reqs": [...], "tests": [...]}} or None.
1521
+
1522
+ None signals "no per-class grouping requested" — use the legacy single class.
1523
+ """
1524
+ if not any(getattr(r, "conformance_class", None) for r in profile.requirements):
1525
+ return None
1526
+ from collections import OrderedDict
1527
+ groups: "OrderedDict[str, dict]" = OrderedDict()
1528
+ req_class: dict[str, str] = {}
1529
+ for r in profile.requirements:
1530
+ cls = getattr(r, "conformance_class", None) or "core"
1531
+ req_class[r.id] = cls
1532
+ groups.setdefault(cls, {"reqs": [], "tests": []})["reqs"].append(r)
1533
+ for t in profile.abstract_tests:
1534
+ cls = req_class.get(t.requirement_id, "core")
1535
+ groups.setdefault(cls, {"reqs": [], "tests": []})["tests"].append(t)
1536
+ return groups
1537
+
1538
+
1539
+ def _class_req_uri(profile: ServiceProfile, cls: str) -> str:
1540
+ return f"{profile.spec_uri_base.rstrip('/')}/req/{cls}"
1541
+
1542
+
1543
+ def _class_conf_uri(profile: ServiceProfile, cls: str) -> str:
1544
+ return f"{profile.spec_uri_base.rstrip('/')}/conf/{cls}"
1545
+
1546
+
1547
+ def _req_class_adoc_for(profile: ServiceProfile, cls: str, reqs: list) -> str:
1548
+ lines = [
1549
+ f"[[req_class_{cls}]]".replace("-", "_"),
1550
+ "[requirements_class]",
1551
+ "====",
1552
+ "[%metadata]",
1553
+ f"identifier:: {_class_req_uri(profile, cls)}",
1554
+ f"target-type:: {profile.title} Profile Standard",
1555
+ ]
1556
+ for req in reqs:
1557
+ lines.append(f"requirement:: /req/{cls}/{req.id}")
1558
+ lines.append("====")
1559
+ return "\n".join(lines) + "\n"
1560
+
1561
+
1562
+ def _conf_class_adoc_for(profile: ServiceProfile, cls: str, tests: list) -> str:
1563
+ lines = [
1564
+ f"[[ats_class_{cls}]]".replace("-", "_"),
1565
+ "[conformance_class]",
1566
+ "====",
1567
+ "[%metadata]",
1568
+ f"identifier:: {_class_conf_uri(profile, cls)}",
1569
+ f"target:: {_class_req_uri(profile, cls)}",
1570
+ ]
1571
+ for test in tests:
1572
+ lines.append(f"abstract-test:: /conf/{cls}/{test.id}")
1573
+ lines.append("====")
1574
+ return "\n".join(lines) + "\n"
1575
+
1576
+
1577
+ def _individual_req_adoc_for(profile: ServiceProfile, cls: str, req) -> str:
1578
+ anchor = f"req_{cls}_{req.id}".replace("/", "_").replace("-", "_")
1579
+ lines = [
1580
+ f"[[{anchor}]]",
1581
+ "[requirement]",
1582
+ "====",
1583
+ "[%metadata]",
1584
+ f"identifier:: /req/{cls}/{req.id}",
1585
+ f"statement:: {req.statement}",
1586
+ ]
1587
+ for part in req.parts:
1588
+ lines.append(f"part:: {part}")
1589
+ lines.append("====")
1590
+ return "\n".join(lines) + "\n"
1591
+
1592
+
1593
+ def _individual_test_adoc_for(profile: ServiceProfile, cls: str, test) -> str:
1594
+ anchor = f"ats_{cls}_{test.id}".replace("/", "_").replace("-", "_")
1595
+ lines = [
1596
+ f"[[{anchor}]]",
1597
+ "[abstract_test]",
1598
+ "====",
1599
+ "[%metadata]",
1600
+ f"identifier:: /conf/{cls}/{test.id}",
1601
+ f"target:: /req/{cls}/{test.requirement_id}",
1602
+ f"test-purpose:: Validate that {test.id.replace('-', ' ')} is correctly implemented.",
1603
+ "test-method::",
1604
+ ]
1605
+ for step in test.steps:
1606
+ lines.append(f"step:: {step}")
1607
+ lines.append("====")
1608
+ return "\n".join(lines) + "\n"
1609
+
1610
+
1407
1611
  # ---------------------------------------------------------------------------
1408
1612
  # Metanorma root document
1409
1613
  # ---------------------------------------------------------------------------
@@ -2018,13 +2222,44 @@ def _terms_adoc(profile: ServiceProfile) -> str:
2018
2222
  def _build_sections(profile: ServiceProfile) -> dict[str, str]:
2019
2223
  """Return the minimal boilerplate sections required by Metanorma OGC."""
2020
2224
  m = profile.document_metadata
2021
- conf_uris = [profile.conf_uri]
2022
- req_includes = "\n".join(
2023
- f"include::../requirements/core/REQ_{r.id}.adoc[]" for r in profile.requirements
2024
- )
2025
- ats_includes = "\n".join(
2026
- f"include::../abstract_tests/core/ATS_{t.id}.adoc[]" for t in profile.abstract_tests
2225
+ groups = _requirement_class_groups(profile)
2226
+ conf_uris = (
2227
+ [_class_conf_uri(profile, cls) for cls in groups]
2228
+ if groups is not None
2229
+ else [profile.conf_uri]
2027
2230
  )
2231
+ if groups is None:
2232
+ req_includes = "\n".join(
2233
+ f"include::../requirements/core/REQ_{r.id}.adoc[]" for r in profile.requirements
2234
+ )
2235
+ ats_includes = "\n".join(
2236
+ f"include::../abstract_tests/core/ATS_{t.id}.adoc[]" for t in profile.abstract_tests
2237
+ )
2238
+ req_class_includes = "include::../requirements/requirements_class_core.adoc[]"
2239
+ ats_class_includes = "include::../abstract_tests/ATS_class_core.adoc[]"
2240
+ else:
2241
+ # One requirements class block + its requirement includes, per class.
2242
+ req_blocks: list[str] = []
2243
+ ats_blocks: list[str] = []
2244
+ for cls, members in groups.items():
2245
+ req_blocks.append(
2246
+ f"include::../requirements/requirements_class_{cls}.adoc[]\n\n"
2247
+ + "\n".join(
2248
+ f"include::../requirements/{cls}/REQ_{r.id}.adoc[]"
2249
+ for r in members["reqs"]
2250
+ )
2251
+ )
2252
+ ats_blocks.append(
2253
+ f"include::../abstract_tests/ATS_class_{cls}.adoc[]\n\n"
2254
+ + "\n".join(
2255
+ f"include::../abstract_tests/{cls}/ATS_{t.id}.adoc[]"
2256
+ for t in members["tests"]
2257
+ )
2258
+ )
2259
+ req_includes = "\n\n".join(req_blocks)
2260
+ ats_includes = "\n\n".join(ats_blocks)
2261
+ req_class_includes = ""
2262
+ ats_class_includes = ""
2028
2263
 
2029
2264
  # --- Submitters table rows (page iv) ---
2030
2265
  # Prefer the structured `submitters` list (each with its own affiliation).
@@ -2147,12 +2382,12 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
2147
2382
  ),
2148
2383
  "sections/06-requirements.adoc": (
2149
2384
  "== Requirements\n\n"
2150
- "include::../requirements/requirements_class_core.adoc[]\n\n"
2385
+ + (req_class_includes + "\n\n" if req_class_includes else "")
2151
2386
  + req_includes + "\n"
2152
2387
  ),
2153
2388
  "sections/07-abstract-tests.adoc": (
2154
2389
  "[appendix,obligation=normative]\n== Abstract Test Suite\n\n"
2155
- "include::../abstract_tests/ATS_class_core.adoc[]\n\n"
2390
+ + (ats_class_includes + "\n\n" if ats_class_includes else "")
2156
2391
  + ats_includes + "\n"
2157
2392
  ),
2158
2393
  }
@@ -2189,19 +2424,37 @@ def generate(profile: ServiceProfile, output_dir: Path) -> None:
2189
2424
  if profile.pubsub:
2190
2425
  safe_write("asyncapi.yaml", yaml.dump(build_asyncapi(profile), sort_keys=False, allow_unicode=True))
2191
2426
 
2192
- # Requirements class
2193
- safe_write("requirements/requirements_class_core.adoc", _req_adoc(profile))
2194
-
2195
- # Individual requirements
2196
- for req in profile.requirements:
2197
- safe_write(f"requirements/core/REQ_{req.id}.adoc", _individual_req_adoc(profile, req.id))
2198
-
2199
- # Conformance class
2200
- safe_write("abstract_tests/ATS_class_core.adoc", _conf_adoc(profile))
2201
-
2202
- # Individual abstract tests
2203
- for test in profile.abstract_tests:
2204
- safe_write(f"abstract_tests/core/ATS_{test.id}.adoc", _individual_test_adoc(profile, test.id))
2427
+ _req_groups = _requirement_class_groups(profile)
2428
+ if _req_groups is None:
2429
+ # Single-class (legacy) layout — one 'core' requirements/conformance class.
2430
+ safe_write("requirements/requirements_class_core.adoc", _req_adoc(profile))
2431
+ for req in profile.requirements:
2432
+ safe_write(f"requirements/core/REQ_{req.id}.adoc", _individual_req_adoc(profile, req.id))
2433
+ safe_write("abstract_tests/ATS_class_core.adoc", _conf_adoc(profile))
2434
+ for test in profile.abstract_tests:
2435
+ safe_write(f"abstract_tests/core/ATS_{test.id}.adoc", _individual_test_adoc(profile, test.id))
2436
+ else:
2437
+ # Multi-class layout — one requirements/conformance class per key, with
2438
+ # requirement and abstract-test files grouped under class subdirectories.
2439
+ for cls, members in _req_groups.items():
2440
+ safe_write(
2441
+ f"requirements/requirements_class_{cls}.adoc",
2442
+ _req_class_adoc_for(profile, cls, members["reqs"]),
2443
+ )
2444
+ for req in members["reqs"]:
2445
+ safe_write(
2446
+ f"requirements/{cls}/REQ_{req.id}.adoc",
2447
+ _individual_req_adoc_for(profile, cls, req),
2448
+ )
2449
+ safe_write(
2450
+ f"abstract_tests/ATS_class_{cls}.adoc",
2451
+ _conf_class_adoc_for(profile, cls, members["tests"]),
2452
+ )
2453
+ for test in members["tests"]:
2454
+ safe_write(
2455
+ f"abstract_tests/{cls}/ATS_{test.id}.adoc",
2456
+ _individual_test_adoc_for(profile, cls, test),
2457
+ )
2205
2458
 
2206
2459
  # Profile config (round-trip)
2207
2460
  safe_write(
@@ -177,6 +177,45 @@ class Collection(EDRCollection):
177
177
  metadata_date: Optional[str] = None
178
178
  resource_service_publish_date: Optional[str] = None
179
179
  resource_default_locale: Optional[str] = None
180
+ conformance_classes: List[str] = Field(
181
+ default_factory=list,
182
+ description=(
183
+ "Short keys of the profile's conformance classes this collection "
184
+ "implements (e.g. ['core', 'insitu-observations']). Drives which "
185
+ "entries in ServiceProfile.conformance_class_requirements are enforced "
186
+ "for this collection, so a single profile document can hold collections "
187
+ "of different EDR Part 3 requirements classes. Round-tripped into "
188
+ "profile_config.json and surfaced as x-conformance-classes in the "
189
+ "generated OpenAPI."
190
+ ),
191
+ )
192
+ parameter_schema: Optional[dict] = Field(
193
+ default=None,
194
+ description=(
195
+ "Per-collection JSON Schema fragment used as the additionalProperties "
196
+ "definition for this collection's parameter_names in the generated "
197
+ "OpenAPI. Overrides the profile-level parameter_schema for this "
198
+ "collection only. Use this when collections in one profile need "
199
+ "different parameter constraints — e.g. an insitu collection requiring "
200
+ "metocean:standard_name / metocean:level / measurementType while an NWP "
201
+ "collection uses ECMWF short-name conventions. Must be a valid JSON "
202
+ "Schema object."
203
+ ),
204
+ )
205
+
206
+ @field_validator("parameter_schema")
207
+ @classmethod
208
+ def _valid_parameter_schema(cls, v: dict | None) -> dict | None:
209
+ if v is None:
210
+ return v
211
+ if not isinstance(v, dict):
212
+ raise ValueError("parameter_schema must be a JSON Schema object (dict)")
213
+ if not any(k in v for k in ("type", "properties", "$ref", "allOf", "anyOf", "oneOf")):
214
+ raise ValueError(
215
+ "parameter_schema must contain at least one of: "
216
+ "type, properties, $ref, allOf, anyOf, oneOf"
217
+ )
218
+ return v
180
219
 
181
220
 
182
221
  # ---------------------------------------------------------------------------
@@ -198,6 +237,17 @@ class Requirement(BaseModel):
198
237
  id: Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9\-]*$")]
199
238
  statement: str
200
239
  parts: list[str] = Field(min_length=1)
240
+ conformance_class: str | None = Field(
241
+ default=None,
242
+ description=(
243
+ "Short key of the requirements/conformance class this requirement "
244
+ "belongs to (e.g. 'core', 'insitu-observations', 'nwp'). When any "
245
+ "requirement sets this, the generated AsciiDoc/PDF groups requirements "
246
+ "and abstract tests into one requirements class per key (each with its "
247
+ "own class URI under spec_uri_base). When unset on all requirements, "
248
+ "the generator keeps its single-class behaviour."
249
+ ),
250
+ )
201
251
 
202
252
  @field_validator("id")
203
253
  @classmethod
@@ -729,6 +779,38 @@ class PagingConfig(BaseModel):
729
779
  max_limit: int = Field(default=10000, description="Maximum allowed limit for features returned", ge=1)
730
780
 
731
781
 
782
+ class ConformanceClassConstraints(BaseModel):
783
+ """Constraints scoped to a single OGC API - EDR Part 3 conformance class.
784
+
785
+ A profile document (e.g. the MetOcean EDR Profile) defines several
786
+ requirements/conformance classes — Core, Insitu observations, NWP, Data
787
+ query response format — and different collections in the same service may
788
+ implement different classes. These per-class constraints are applied only
789
+ to collections whose ``conformance_classes`` list includes the matching
790
+ class key, so one profile can enforce, say, ``[locations, area, radius]``
791
+ on its insitu collections and ``[position, cube]`` on its NWP collections.
792
+
793
+ The equivalent profile-level fields (``required_data_queries``,
794
+ ``radius_within_units_required``) remain a shorthand that applies to *every*
795
+ collection; class-scoped constraints are additive on top of them.
796
+ """
797
+ required_data_queries: list[str] = Field(
798
+ default_factory=list,
799
+ description=(
800
+ "Data query types every collection implementing this conformance class "
801
+ "must define (e.g. ['locations', 'area', 'radius'] for insitu, "
802
+ "['position', 'cube'] for NWP)."
803
+ ),
804
+ )
805
+ radius_within_units_required: list[str] = Field(
806
+ default_factory=list,
807
+ description=(
808
+ "Unit tokens each radius query's within_units array must contain for "
809
+ "collections implementing this conformance class (e.g. ['m'])."
810
+ ),
811
+ )
812
+
813
+
732
814
  # ---------------------------------------------------------------------------
733
815
  # Root model — the authoritative profile definition
734
816
  # ---------------------------------------------------------------------------
@@ -852,6 +934,75 @@ class ServiceProfile(BaseModel):
852
934
  ),
853
935
  )
854
936
 
937
+ # --- Collection-level conformance constraints ---------------------------
938
+ # Optional profile-level rules that mirror MetOcean EDR Profile core
939
+ # requirements. Each is off by default so existing profiles are unaffected;
940
+ # set them to opt a profile into the corresponding validation.
941
+
942
+ collection_title_max_length: int | None = Field(
943
+ default=None,
944
+ ge=1,
945
+ description=(
946
+ "When set, every collection title must be present and no longer than this "
947
+ "many characters. Implements the MetOcean /req/core/collection_title rule "
948
+ "(title required, <= 50 characters)."
949
+ ),
950
+ )
951
+ require_license_link: bool = Field(
952
+ default=False,
953
+ description=(
954
+ "When true, every collection must declare a link with rel='license'. "
955
+ "The link's type must equal license_link_type when that field is set. "
956
+ "Implements the MetOcean /req/core/collection_license rule."
957
+ ),
958
+ )
959
+ license_link_type: str | None = Field(
960
+ default="text/html",
961
+ description=(
962
+ "Expected media type of the rel='license' link when require_license_link "
963
+ "is true. Set to null to accept any type. Defaults to 'text/html' per the "
964
+ "MetOcean profile."
965
+ ),
966
+ )
967
+ required_data_queries: list[str] = Field(
968
+ default_factory=list,
969
+ description=(
970
+ "Data query types (e.g. 'locations', 'area', 'radius', 'position', 'cube') "
971
+ "that every collection with data_queries must define. Implements the "
972
+ "'array must contain value' rule for the mandatory data-query sets "
973
+ "(MetOcean /req/*/collection_data_queries)."
974
+ ),
975
+ )
976
+ radius_within_units_required: list[str] = Field(
977
+ default_factory=list,
978
+ description=(
979
+ "Unit tokens that every radius query's within_units array must contain "
980
+ "(e.g. ['m']). Implements the MetOcean /req/core/collection_radius_data_query "
981
+ "rule that within_units SHALL contain 'm'."
982
+ ),
983
+ )
984
+ locations_feature_required_properties: list[str] = Field(
985
+ default_factory=list,
986
+ description=(
987
+ "Feature properties (e.g. ['name']) that the generated /locations response "
988
+ "schema marks as required on each GeoJSON feature, in addition to the always-"
989
+ "required string 'id'. Implements the MetOcean "
990
+ "/req/core/locations_query_response_format rule."
991
+ ),
992
+ )
993
+ conformance_class_requirements: dict[str, ConformanceClassConstraints] = Field(
994
+ default_factory=dict,
995
+ description=(
996
+ "Per-conformance-class constraints keyed by class short name (e.g. "
997
+ "'insitu-observations', 'nwp'). Applied only to collections whose "
998
+ "conformance_classes list includes the key. Lets one profile document "
999
+ "model several EDR Part 3 requirements classes with different mandatory "
1000
+ "data-query sets. The flat required_data_queries / "
1001
+ "radius_within_units_required fields still apply to every collection; "
1002
+ "class-scoped constraints are additive on top of them."
1003
+ ),
1004
+ )
1005
+
855
1006
  # OGC identifiers derived from name and spec_uri_base — not user-supplied directly
856
1007
  @property
857
1008
  def req_uri(self) -> str:
@@ -1078,6 +1229,160 @@ class ServiceProfile(BaseModel):
1078
1229
  )
1079
1230
  return self
1080
1231
 
1232
+ @model_validator(mode="after")
1233
+ def validate_collection_title_length(self) -> ServiceProfile:
1234
+ """Enforce collection_title_max_length (MetOcean /req/core/collection_title)."""
1235
+ if self.collection_title_max_length is None:
1236
+ return self
1237
+ limit = self.collection_title_max_length
1238
+ for coll in self.collections:
1239
+ title = getattr(coll, "title", None)
1240
+ if not title:
1241
+ raise ValueError(
1242
+ f"Collection '{coll.id}' must declare a title "
1243
+ f"(collection_title_max_length is set to {limit})"
1244
+ )
1245
+ if len(title) > limit:
1246
+ raise ValueError(
1247
+ f"Collection '{coll.id}' title is {len(title)} characters; "
1248
+ f"exceeds the maximum of {limit} "
1249
+ f"(collection_title_max_length)"
1250
+ )
1251
+ return self
1252
+
1253
+ @model_validator(mode="after")
1254
+ def validate_license_link(self) -> ServiceProfile:
1255
+ """Require a rel='license' link per MetOcean /req/core/collection_license."""
1256
+ if not self.require_license_link:
1257
+ return self
1258
+ for coll in self.collections:
1259
+ links = getattr(coll, "links", None) or []
1260
+ license_links = [
1261
+ lk for lk in links if getattr(lk, "rel", None) == "license"
1262
+ ]
1263
+ if not license_links:
1264
+ raise ValueError(
1265
+ f"Collection '{coll.id}' must declare a link with rel='license' "
1266
+ f"(require_license_link is set)"
1267
+ )
1268
+ if self.license_link_type is not None:
1269
+ if not any(
1270
+ getattr(lk, "type", None) == self.license_link_type
1271
+ for lk in license_links
1272
+ ):
1273
+ raise ValueError(
1274
+ f"Collection '{coll.id}' rel='license' link must have "
1275
+ f"type='{self.license_link_type}' "
1276
+ f"(license_link_type)"
1277
+ )
1278
+ return self
1279
+
1280
+ def _effective_required_data_queries(self, coll: "Collection") -> set[str]:
1281
+ """Mandatory data-query types for a collection.
1282
+
1283
+ Combines the profile-wide required_data_queries (applies to every
1284
+ collection) with the required_data_queries of every conformance class
1285
+ the collection declares via conformance_classes.
1286
+ """
1287
+ required: set[str] = set(self.required_data_queries)
1288
+ for cls_name in getattr(coll, "conformance_classes", []) or []:
1289
+ cls = self.conformance_class_requirements.get(cls_name)
1290
+ if cls:
1291
+ required.update(cls.required_data_queries)
1292
+ return required
1293
+
1294
+ def _effective_radius_within_units(self, coll: "Collection") -> set[str]:
1295
+ """Required radius within_units tokens for a collection (flat + class-scoped)."""
1296
+ required: set[str] = set(self.radius_within_units_required)
1297
+ for cls_name in getattr(coll, "conformance_classes", []) or []:
1298
+ cls = self.conformance_class_requirements.get(cls_name)
1299
+ if cls:
1300
+ required.update(cls.radius_within_units_required)
1301
+ return required
1302
+
1303
+ @model_validator(mode="after")
1304
+ def validate_required_data_queries(self) -> ServiceProfile:
1305
+ """Ensure each collection defines its mandatory data query types.
1306
+
1307
+ Mandatory sets come from the profile-wide required_data_queries plus any
1308
+ conformance-class-scoped requirements the collection opts into.
1309
+ """
1310
+ for coll in self.collections:
1311
+ required = self._effective_required_data_queries(coll)
1312
+ if not required:
1313
+ continue
1314
+ if not coll.data_queries:
1315
+ raise ValueError(
1316
+ f"Collection '{coll.id}' must define data_queries including "
1317
+ f"{sorted(required)} (required_data_queries)"
1318
+ )
1319
+ present = {
1320
+ name for name, val in coll.data_queries if val is not None
1321
+ }
1322
+ missing = required - present
1323
+ if missing:
1324
+ raise ValueError(
1325
+ f"Collection '{coll.id}' data_queries is missing required "
1326
+ f"query type(s) {sorted(missing)} (required by "
1327
+ f"required_data_queries / conformance_class_requirements)"
1328
+ )
1329
+ return self
1330
+
1331
+ @model_validator(mode="after")
1332
+ def validate_radius_within_units(self) -> ServiceProfile:
1333
+ """Ensure each radius query's within_units contains the required unit tokens."""
1334
+ for coll in self.collections:
1335
+ required = self._effective_radius_within_units(coll)
1336
+ if not required or not coll.data_queries:
1337
+ continue
1338
+ for qt_name, qt_val in coll.data_queries:
1339
+ if qt_name != "radius" or qt_val is None:
1340
+ continue
1341
+ variables = qt_val.link.variables if qt_val.link else None
1342
+ extra = (
1343
+ variables.model_extra
1344
+ if variables is not None and hasattr(variables, "model_extra")
1345
+ else {}
1346
+ ) or {}
1347
+ within_units = (
1348
+ getattr(variables, "within_units", None)
1349
+ or extra.get("within_units")
1350
+ or []
1351
+ )
1352
+ missing = [u for u in sorted(required) if u not in within_units]
1353
+ if missing:
1354
+ raise ValueError(
1355
+ f"Collection '{coll.id}' radius data query within_units "
1356
+ f"{list(within_units)} must contain {missing} "
1357
+ f"(radius_within_units_required / conformance_class_requirements)"
1358
+ )
1359
+ return self
1360
+
1361
+ @model_validator(mode="after")
1362
+ def validate_collection_conformance_classes(self) -> ServiceProfile:
1363
+ """Flag conformance_classes keys that have no matching requirements entry.
1364
+
1365
+ A collection may legitimately declare a pure conformance class (e.g.
1366
+ 'core') that carries no extra constraints, so this only errors when a
1367
+ class key looks like a typo: it is referenced by a collection, is not a
1368
+ key in conformance_class_requirements, AND conformance_class_requirements
1369
+ is non-empty (i.e. the profile author is using the class-scoped model).
1370
+ """
1371
+ if not self.conformance_class_requirements:
1372
+ return self
1373
+ known = set(self.conformance_class_requirements)
1374
+ # 'core' is always acceptable as a bare class even without constraints.
1375
+ known.add("core")
1376
+ for coll in self.collections:
1377
+ for cls_name in getattr(coll, "conformance_classes", []) or []:
1378
+ if cls_name not in known:
1379
+ raise ValueError(
1380
+ f"Collection '{coll.id}' declares conformance class "
1381
+ f"'{cls_name}' which is not defined in "
1382
+ f"conformance_class_requirements {sorted(self.conformance_class_requirements)}"
1383
+ )
1384
+ return self
1385
+
1081
1386
 
1082
1387
  def _compile_optional_pattern(label: str, pattern: str | None) -> re.Pattern | None:
1083
1388
  """Compile a regex pattern string, raising ValueError on invalid syntax."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.4.1
3
+ Version: 3.5.0
4
4
  Summary: Authoritative tooling for creating OGC API Service Profiles (EDR, Features)
5
5
  Author-email: Shane Mill <shane.mill@noaa.gov>
6
6
  License: Apache License
@@ -342,6 +342,13 @@ abstract_tests: []
342
342
  | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
343
343
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
344
344
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
345
+ | `collection_title_max_length` | int | no | When set, every collection title must be present and within this many characters (MetOcean `/req/core/collection_title`) |
346
+ | `require_license_link` | bool | no | When `true`, every collection must declare a `rel='license'` link (MetOcean `/req/core/collection_license`). Defaults to `false` |
347
+ | `license_link_type` | string | no | Expected media type of the `rel='license'` link when `require_license_link` is set. Defaults to `text/html`; set to null to accept any type |
348
+ | `required_data_queries` | list | no | Data-query types every collection must define (e.g. `[locations, area, radius]`) — enforces the mandatory data-query sets |
349
+ | `radius_within_units_required` | list | no | Unit tokens each radius query's `within_units` must contain (e.g. `[m]`) (MetOcean `/req/core/collection_radius_data_query`) |
350
+ | `locations_feature_required_properties` | list | no | Feature properties (e.g. `[name]`) marked required in the generated `/locations` GeoJSON response schema, in addition to the always-required string `id` |
351
+ | `conformance_class_requirements` | object | no | Map of conformance-class short name → constraints (`required_data_queries`, `radius_within_units_required`) applied only to collections that declare the class. Lets one profile document model several EDR Part 3 requirements classes (see below) |
345
352
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
346
353
  | `requirements` | list | no | Normative requirements for the AsciiDoc/PDF |
347
354
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
@@ -381,6 +388,8 @@ Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model
381
388
  | `output_formats` | no | Format names this collection supports (e.g. `[CoverageJSON, GeoJSON]`) |
382
389
  | `data_queries` | no | EDR query types: `position`, `area`, `radius`, `cube`, `trajectory`, `corridor`, `locations`, `items`, `instances` |
383
390
  | `parameter_names` | no | Map of parameter id → Parameter object. All parameters must have `unit` and `observedProperty` |
391
+ | `conformance_classes` | no | Profile conformance-class short names this collection implements (e.g. `[core, insitu-observations]`). Selects which `conformance_class_requirements` are enforced; surfaced as `x-conformance-classes` in the OpenAPI |
392
+ | `parameter_schema` | no | Per-collection JSON Schema for parameter objects, overriding the profile-level `parameter_schema` for this collection only (precedence: collection → profile → default) |
384
393
 
385
394
  #### `data_queries` example
386
395
 
@@ -513,6 +522,38 @@ extent_requirements:
513
522
 
514
523
  ---
515
524
 
525
+ ### Multiple conformance classes in one profile
526
+
527
+ OGC API - EDR Part 3 profiles are modular: a single profile document can define several requirements/conformance classes, and different collections in the same service may implement different classes. The MetOcean EDR Profile is a good example — Core applies everywhere, but only in-situ collections must offer `locations`/`area`/`radius` while NWP collections must offer `position`/`cube`.
528
+
529
+ Model this with `conformance_class_requirements` (profile level) plus `conformance_classes` (per collection):
530
+
531
+ ```yaml
532
+ # Core-level rules apply to every collection:
533
+ collection_title_max_length: 50
534
+ require_license_link: true
535
+
536
+ # Class-scoped rules apply only where a collection opts in:
537
+ conformance_class_requirements:
538
+ insitu-observations:
539
+ required_data_queries: [locations, area, radius]
540
+ radius_within_units_required: [m]
541
+ nwp:
542
+ required_data_queries: [position, cube]
543
+
544
+ collections:
545
+ - id: insitu-observations
546
+ conformance_classes: [core, insitu-observations] # must have locations/area/radius
547
+ # ...
548
+ - id: nwp-forecast
549
+ conformance_classes: [core, nwp] # must have position/cube
550
+ # ...
551
+ ```
552
+
553
+ The flat `required_data_queries` / `radius_within_units_required` fields still apply to *every* collection (the Core-applies-to-all shorthand); class-scoped rules are additive. See `examples/metocean_profile.yaml` for a full worked profile spanning all four MetOcean classes.
554
+
555
+ ---
556
+
516
557
  ### `output_formats`
517
558
 
518
559
  Maps format names (referenced by `collections[].output_formats` and `data_queries.*.variables.output_formats`) to a media type and, optionally, a schema reference. The `schema_ref` flows into the generated OpenAPI response `content` for that format — use it to point a format at a specific schema (e.g. a DGIWG-published schema for GeoTIFF or CoverageJSON) instead of the tool's built-in default.
@@ -898,6 +939,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
898
939
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
899
940
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
900
941
  | `collection_id_pattern` enforced | All collection IDs must match if set |
942
+ | `collection_title_max_length` enforced | If set, every collection title must be present and within the limit |
943
+ | `require_license_link` enforced | If set, every collection must have a `rel='license'` link (of `license_link_type` when specified) |
944
+ | `required_data_queries` enforced | If set, every collection with `data_queries` must define each listed query type |
945
+ | `radius_within_units_required` enforced | If set, each radius query's `within_units` must contain every listed unit token |
946
+ | `conformance_class_requirements` enforced | Class-scoped `required_data_queries` / `radius_within_units_required` applied to collections whose `conformance_classes` include the class key |
947
+ | `conformance_classes` references known class | When `conformance_class_requirements` is set, a collection's `conformance_classes` may only use `core` or a defined class key (catches typos) |
901
948
  | Abstract test IDs match requirements | `requirement_id` must reference an existing requirement |
902
949
  | Requirement IDs | Must match `^[a-z0-9][a-z0-9\-]*$`, no trailing hyphen |
903
950