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.
- {oapi_profile_builder-3.4.1/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.5.0}/PKG-INFO +48 -1
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/README.md +47 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/pyproject.toml +1 -1
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/generate.py +290 -37
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/models.py +305 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0/src/oapi_profile_builder.egg-info}/PKG-INFO +48 -1
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/LICENSE +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/setup.cfg +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/__init__.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cite.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cite_features.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cli.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/compile.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cover.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/server_validation.py +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
- {oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
{oapi_profile_builder-3.4.1/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.5.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oapi-profile-builder
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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" }
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/generate.py
RENAMED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
452
|
-
#
|
|
453
|
-
|
|
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
|
-
|
|
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,
|
|
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": {
|
|
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
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
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(
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/models.py
RENAMED
|
@@ -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."""
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0/src/oapi_profile_builder.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oapi-profile-builder
|
|
3
|
-
Version: 3.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/cite_features.py
RENAMED
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.4.1 → oapi_profile_builder-3.5.0}/src/oapi_profile_builder/compile.py
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
|