oapi-profile-builder 2.4.0__tar.gz → 3.0.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 (19) hide show
  1. {oapi_profile_builder-2.4.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.0.0}/PKG-INFO +55 -15
  2. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/README.md +54 -14
  3. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/generate.py +25 -20
  5. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/models.py +134 -82
  6. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0/src/oapi_profile_builder.egg-info}/PKG-INFO +55 -15
  7. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/LICENSE +0 -0
  8. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/setup.cfg +0 -0
  9. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/__init__.py +0 -0
  10. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cite.py +0 -0
  11. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cite_features.py +0 -0
  12. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cli.py +0 -0
  13. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/compile.py +0 -0
  14. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/server_validation.py +0 -0
  15. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  16. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  17. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  18. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  19. {oapi_profile_builder-2.4.0 → oapi_profile_builder-3.0.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: 2.4.0
3
+ Version: 3.0.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
@@ -234,18 +234,27 @@ keywords:
234
234
  - my-parameter
235
235
  - my-domain
236
236
 
237
+ # ── Profile-level constraints ─────────────────────────────────────────────
238
+ # Define what any conforming implementation MUST support.
239
+ # Validated at build time and embedded in the generated OpenAPI as
240
+ # enum/pattern constraints so runtime tools can enforce them.
237
241
  required_conformance_classes:
238
242
  - "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core"
239
243
 
240
244
  extent_requirements:
241
245
  minimum_bbox: [-180, -90, 180, 90]
242
- allowed_crs:
243
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
246
+ extent_crs:
247
+ allowed:
248
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
244
249
 
245
250
  output_formats:
246
251
  - name: GeoJSON
247
252
  media_type: application/geo+json
248
253
 
254
+ # ── Reference metadata ────────────────────────────────────────────────────
255
+ # Describes the structure of collections a conforming service should expose.
256
+ # For dynamic fields (extent intervals, custom dimension values), use the
257
+ # full possible range — not a snapshot of current live data.
249
258
  collections:
250
259
  - id: my_collection
251
260
  links:
@@ -264,6 +273,11 @@ collections:
264
273
  unit:
265
274
  label: Celsius
266
275
  symbol: "°C"
276
+
277
+ # ── Normative requirements and abstract tests ─────────────────────────────
278
+ # Drive the AsciiDoc/PDF output. Tests must reference a valid requirement id.
279
+ requirements: []
280
+ abstract_tests: []
267
281
  ```
268
282
 
269
283
  ### Top-level fields
@@ -297,6 +311,16 @@ collections:
297
311
 
298
312
  Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model — the same schema an EDR server returns at `/collections/{id}`.
299
313
 
314
+ > **Service-level vs collection-level fields**
315
+ >
316
+ > The profile has two distinct levels that serve different purposes — they don't merge or override each other:
317
+ >
318
+ > - **Service-level fields** (`extent_requirements`, `parameter_schema`, `parameter_name_pattern`, `collection_id_pattern`) define *constraints* that any conforming implementation must satisfy. They are validated at build time and embedded in the generated OpenAPI as schema constraints (enums, patterns) so runtime tools can enforce them against a live server.
319
+ >
320
+ > - **`collections[]`** provides *reference metadata* — the structure and representative values of collections a conforming service should expose. This mirrors what an EDR server returns at `/collections/{id}` and drives the generated OpenAPI paths and response schemas.
321
+ >
322
+ > For fields that are inherently dynamic (e.g. `extent.temporal.interval`, `extent.custom[].values`), use representative values that describe the *full possible range*, not a snapshot of current live data. For example, use an open-ended temporal interval `["2020-01-01T00:00:00Z", null]` rather than a specific end date, and list all valid custom dimension values rather than only those currently in the dataset. The `validate-server` command tests API structure and response schema conformance — it does not assert that specific data values in responses match the profile config exactly.
323
+
300
324
  | Field | Required | Description |
301
325
  |---|---|---|
302
326
  | `id` | yes | Collection identifier |
@@ -363,25 +387,41 @@ parameter_names:
363
387
 
364
388
  ### `extent_requirements`
365
389
 
366
- Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI.
390
+ Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI as `enum` or `pattern` constraints on the collection response schema.
391
+
392
+ These are **profile-level constraints** — they define what any conforming implementation must support, independent of the specific values a live service happens to have at a given moment.
393
+
394
+ CRS/TRS/VRS constraints are split into two distinct concerns:
395
+
396
+ - `extent_crs` / `extent_trs` / `extent_vrs` — constrain the **single value** used to express the extent (`extent.spatial.crs`, `extent.temporal.trs`, `extent.vertical.vrs`). Typically just CRS84 / Gregorian.
397
+ - `supported_crs` / `supported_trs` / `supported_vrs` — constrain the **list of values** the service supports for queries (the top-level `crs` array and `data_queries.*.variables.crs_details`). Usually broader than the extent constraint.
398
+
399
+ Each constraint uses either `allowed` (exact list) or `pattern` (regex) — not both.
367
400
 
368
401
  ```yaml
369
402
  extent_requirements:
370
403
  minimum_bbox: [-180, -90, 180, 90]
371
404
 
372
- # Option A: exact list
373
- allowed_crs:
374
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
375
- - "http://www.opengis.net/def/crs/EPSG/0/4326"
376
-
377
- # Option B: regex (accepts any OGC or EPSG CRS)
378
- # crs_pattern: "^http://www\\.opengis\\.net/def/crs/(OGC|EPSG)/.*$"
379
-
380
- allowed_trs:
381
- - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
405
+ # Extent is always expressed in CRS84
406
+ extent_crs:
407
+ allowed:
408
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
409
+
410
+ # Service supports CRS84 plus any EPSG CRS for queries
411
+ supported_crs:
412
+ pattern: "^(http://www\\.opengis\\.net/def/crs/OGC/1\\.3/CRS84|http://www\\.opengis\\.net/def/crs/EPSG/.*)$"
413
+ # or an explicit list:
414
+ # supported_crs:
415
+ # allowed:
416
+ # - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
417
+ # - "http://www.opengis.net/def/crs/EPSG/0/4326"
418
+
419
+ extent_trs:
420
+ allowed:
421
+ - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
382
422
  ```
383
423
 
384
- Either `allowed_crs` or `crs_pattern` is required. The same enum/regex approach applies to `allowed_trs`/`trs_pattern` and `allowed_vrs`/`vrs_pattern`.
424
+ At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
385
425
 
386
426
  ---
387
427
 
@@ -189,18 +189,27 @@ keywords:
189
189
  - my-parameter
190
190
  - my-domain
191
191
 
192
+ # ── Profile-level constraints ─────────────────────────────────────────────
193
+ # Define what any conforming implementation MUST support.
194
+ # Validated at build time and embedded in the generated OpenAPI as
195
+ # enum/pattern constraints so runtime tools can enforce them.
192
196
  required_conformance_classes:
193
197
  - "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core"
194
198
 
195
199
  extent_requirements:
196
200
  minimum_bbox: [-180, -90, 180, 90]
197
- allowed_crs:
198
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
201
+ extent_crs:
202
+ allowed:
203
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
199
204
 
200
205
  output_formats:
201
206
  - name: GeoJSON
202
207
  media_type: application/geo+json
203
208
 
209
+ # ── Reference metadata ────────────────────────────────────────────────────
210
+ # Describes the structure of collections a conforming service should expose.
211
+ # For dynamic fields (extent intervals, custom dimension values), use the
212
+ # full possible range — not a snapshot of current live data.
204
213
  collections:
205
214
  - id: my_collection
206
215
  links:
@@ -219,6 +228,11 @@ collections:
219
228
  unit:
220
229
  label: Celsius
221
230
  symbol: "°C"
231
+
232
+ # ── Normative requirements and abstract tests ─────────────────────────────
233
+ # Drive the AsciiDoc/PDF output. Tests must reference a valid requirement id.
234
+ requirements: []
235
+ abstract_tests: []
222
236
  ```
223
237
 
224
238
  ### Top-level fields
@@ -252,6 +266,16 @@ collections:
252
266
 
253
267
  Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model — the same schema an EDR server returns at `/collections/{id}`.
254
268
 
269
+ > **Service-level vs collection-level fields**
270
+ >
271
+ > The profile has two distinct levels that serve different purposes — they don't merge or override each other:
272
+ >
273
+ > - **Service-level fields** (`extent_requirements`, `parameter_schema`, `parameter_name_pattern`, `collection_id_pattern`) define *constraints* that any conforming implementation must satisfy. They are validated at build time and embedded in the generated OpenAPI as schema constraints (enums, patterns) so runtime tools can enforce them against a live server.
274
+ >
275
+ > - **`collections[]`** provides *reference metadata* — the structure and representative values of collections a conforming service should expose. This mirrors what an EDR server returns at `/collections/{id}` and drives the generated OpenAPI paths and response schemas.
276
+ >
277
+ > For fields that are inherently dynamic (e.g. `extent.temporal.interval`, `extent.custom[].values`), use representative values that describe the *full possible range*, not a snapshot of current live data. For example, use an open-ended temporal interval `["2020-01-01T00:00:00Z", null]` rather than a specific end date, and list all valid custom dimension values rather than only those currently in the dataset. The `validate-server` command tests API structure and response schema conformance — it does not assert that specific data values in responses match the profile config exactly.
278
+
255
279
  | Field | Required | Description |
256
280
  |---|---|---|
257
281
  | `id` | yes | Collection identifier |
@@ -318,25 +342,41 @@ parameter_names:
318
342
 
319
343
  ### `extent_requirements`
320
344
 
321
- Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI.
345
+ Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI as `enum` or `pattern` constraints on the collection response schema.
346
+
347
+ These are **profile-level constraints** — they define what any conforming implementation must support, independent of the specific values a live service happens to have at a given moment.
348
+
349
+ CRS/TRS/VRS constraints are split into two distinct concerns:
350
+
351
+ - `extent_crs` / `extent_trs` / `extent_vrs` — constrain the **single value** used to express the extent (`extent.spatial.crs`, `extent.temporal.trs`, `extent.vertical.vrs`). Typically just CRS84 / Gregorian.
352
+ - `supported_crs` / `supported_trs` / `supported_vrs` — constrain the **list of values** the service supports for queries (the top-level `crs` array and `data_queries.*.variables.crs_details`). Usually broader than the extent constraint.
353
+
354
+ Each constraint uses either `allowed` (exact list) or `pattern` (regex) — not both.
322
355
 
323
356
  ```yaml
324
357
  extent_requirements:
325
358
  minimum_bbox: [-180, -90, 180, 90]
326
359
 
327
- # Option A: exact list
328
- allowed_crs:
329
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
330
- - "http://www.opengis.net/def/crs/EPSG/0/4326"
331
-
332
- # Option B: regex (accepts any OGC or EPSG CRS)
333
- # crs_pattern: "^http://www\\.opengis\\.net/def/crs/(OGC|EPSG)/.*$"
334
-
335
- allowed_trs:
336
- - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
360
+ # Extent is always expressed in CRS84
361
+ extent_crs:
362
+ allowed:
363
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
364
+
365
+ # Service supports CRS84 plus any EPSG CRS for queries
366
+ supported_crs:
367
+ pattern: "^(http://www\\.opengis\\.net/def/crs/OGC/1\\.3/CRS84|http://www\\.opengis\\.net/def/crs/EPSG/.*)$"
368
+ # or an explicit list:
369
+ # supported_crs:
370
+ # allowed:
371
+ # - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
372
+ # - "http://www.opengis.net/def/crs/EPSG/0/4326"
373
+
374
+ extent_trs:
375
+ allowed:
376
+ - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
337
377
  ```
338
378
 
339
- Either `allowed_crs` or `crs_pattern` is required. The same enum/regex approach applies to `allowed_trs`/`trs_pattern` and `allowed_vrs`/`vrs_pattern`.
379
+ At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
340
380
 
341
381
  ---
342
382
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "2.4.0"
7
+ version = "3.0.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -416,24 +416,26 @@ def _collection_response_schema(coll: Collection,
416
416
  - Full parameter_names objects with all fields
417
417
  - parameter_name_pattern as propertyNames constraint
418
418
  """
419
- crs_schema: dict = {"type": "string"}
420
- trs_schema: dict = {"type": "string"}
421
- vrs_schema: dict = {"type": "string"}
419
+ crs_schema: dict = {"type": "string"} # extent.spatial.crs
420
+ trs_schema: dict = {"type": "string"} # extent.temporal.trs
421
+ vrs_schema: dict = {"type": "string"} # extent.vertical.vrs
422
+ supported_crs_schema: dict = {"type": "string"} # crs[] and crs_details[].crs
422
423
 
423
424
  if profile and profile.extent_requirements:
424
425
  er = profile.extent_requirements
425
- if er.allowed_crs:
426
- crs_schema["enum"] = er.allowed_crs
427
- elif er.crs_pattern:
428
- crs_schema["pattern"] = er.crs_pattern
429
- if er.allowed_trs:
430
- trs_schema["enum"] = er.allowed_trs
431
- elif er.trs_pattern:
432
- trs_schema["pattern"] = er.trs_pattern
433
- if er.allowed_vrs:
434
- vrs_schema["enum"] = er.allowed_vrs
435
- elif er.vrs_pattern:
436
- vrs_schema["pattern"] = er.vrs_pattern
426
+
427
+ def _apply(schema: dict, constraint: "object | None") -> None:
428
+ if constraint is None:
429
+ return
430
+ if getattr(constraint, "allowed", None):
431
+ schema["enum"] = constraint.allowed
432
+ elif getattr(constraint, "pattern", None):
433
+ schema["pattern"] = constraint.pattern
434
+
435
+ _apply(crs_schema, er.extent_crs)
436
+ _apply(trs_schema, er.extent_trs)
437
+ _apply(vrs_schema, er.extent_vrs)
438
+ _apply(supported_crs_schema, er.supported_crs)
437
439
 
438
440
  # Build parameter_names schema — use profile-supplied schema if present,
439
441
  # otherwise fall back to the default EDR Parameter schema.
@@ -459,11 +461,14 @@ def _collection_response_schema(coll: Collection,
459
461
  if coll.output_formats:
460
462
  output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
461
463
 
462
- # Build crs array schema — use collection-level crs list if present
463
- crs_array_schema: dict = {"type": "array", "items": crs_schema}
464
+ # Build crs array schema — use collection-level crs list if present,
465
+ # otherwise apply supported_crs constraint from extent_requirements.
466
+ # supported_crs is distinct from extent_crs: it constrains what the
467
+ # service supports for queries, not how the extent itself is expressed.
464
468
  if coll.crs:
465
- # Collection declares its supported CRS list explicitly
466
- crs_array_schema["items"] = {"type": "string", "enum": list(coll.crs)}
469
+ crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
470
+ else:
471
+ crs_array_schema = {"type": "array", "items": supported_crs_schema}
467
472
 
468
473
  schema: dict = {
469
474
  "type": "object",
@@ -585,7 +590,7 @@ def _collection_response_schema(coll: Collection,
585
590
  "type": "object",
586
591
  "required": ["crs"],
587
592
  "properties": {
588
- "crs": crs_schema,
593
+ "crs": supported_crs_schema,
589
594
  "wkt": {"type": "string"},
590
595
  },
591
596
  },
@@ -60,6 +60,7 @@ from pydantic import AwareDatetime, BaseModel, Field, field_validator, model_val
60
60
  # ---------------------------------------------------------------------------
61
61
 
62
62
  from edr_pydantic.base_model import EdrBaseModel # noqa: E402 — after stdlib imports
63
+ from edr_pydantic.parameter import Parameters # noqa: E402
63
64
 
64
65
 
65
66
  class TemporalWithNullBounds(EdrBaseModel):
@@ -80,18 +81,32 @@ class ExtentWithNullBounds(EDRExtent):
80
81
 
81
82
  class Collection(EDRCollection):
82
83
  """
83
- EDR Collection with a null-aware temporal extent.
84
+ EDR Collection with a null-aware temporal extent and optional parameter_names.
84
85
 
85
- Identical to edr_pydantic.collections.Collection except that
86
- extent uses ExtentWithNullBounds so open-ended intervals like
87
- ["2020-01-01T00:00:00Z", null] are accepted.
86
+ Differences from edr_pydantic.collections.Collection:
88
87
 
89
- post_queries controls whether POST operations are generated for this
90
- collection's data query endpoints. None means inherit the profile-level
91
- allow_post_queries default. Set to True or False to override per-collection.
88
+ - extent uses ExtentWithNullBounds so open-ended intervals like
89
+ ["2020-01-01T00:00:00Z", null] are accepted (spec-compliant).
90
+
91
+ - parameter_names is Optional (default None). edr-pydantic marks it as
92
+ required, but OGC API - EDR Part 3 allows a profile to define the
93
+ *schema* for parameter objects via parameter_schema without mandating
94
+ any specific parameter names in the profile document itself.
95
+
96
+ - post_queries controls whether POST operations are generated for this
97
+ collection's data query endpoints. None means inherit the profile-level
98
+ allow_post_queries default. Set to True or False to override per-collection.
92
99
  """
93
100
 
94
101
  extent: Optional[ExtentWithNullBounds] = None # type: ignore[assignment]
102
+ parameter_names: Optional[Parameters] = Field( # type: ignore[assignment]
103
+ default=None,
104
+ description=(
105
+ "Map of parameter id → Parameter object. "
106
+ "Optional — omit when using parameter_schema to describe the structure "
107
+ "without mandating specific parameter names."
108
+ ),
109
+ )
95
110
  post_queries: Optional[bool] = Field(
96
111
  default=None,
97
112
  description=(
@@ -149,20 +164,87 @@ class SubscriptionFilter(BaseModel):
149
164
  type: FilterType = FilterType.string
150
165
 
151
166
 
167
+ class CrsConstraint(BaseModel):
168
+ """An enum or regex constraint on a CRS/TRS/VRS value or list.
169
+
170
+ Exactly one of `allowed` or `pattern` must be set.
171
+ """
172
+ allowed: list[str] | None = Field(
173
+ default=None,
174
+ description="Enumerated list of accepted values",
175
+ )
176
+ pattern: str | None = Field(
177
+ default=None,
178
+ description="Regular expression that accepted values must match",
179
+ )
180
+
181
+ @model_validator(mode="after")
182
+ def exactly_one(self) -> CrsConstraint:
183
+ if self.allowed is None and self.pattern is None:
184
+ raise ValueError("Either 'allowed' or 'pattern' must be specified")
185
+ if self.allowed is not None and self.pattern is not None:
186
+ raise ValueError("Only one of 'allowed' or 'pattern' may be specified, not both")
187
+ return self
188
+
189
+
152
190
  class ExtentRequirements(BaseModel):
153
- """Profile-level extent restrictions per OGC API - EDR Part 3."""
154
- minimum_bbox: list[float] = Field(min_length=4, max_length=4, description="Minimum spatial bounds [minLon, minLat, maxLon, maxLat]")
155
- allowed_crs: list[str] | None = Field(default=None, description="Enumerated list of valid CRS values")
156
- crs_pattern: str | None = Field(default=None, description="Regular expression defining valid CRS string patterns")
157
- allowed_trs: list[str] | None = Field(default=None, description="Enumerated list of valid TRS values")
158
- trs_pattern: str | None = Field(default=None, description="Regular expression defining valid TRS string patterns")
159
- allowed_vrs: list[str] | None = Field(default=None, description="Enumerated list of valid VRS values")
160
- vrs_pattern: str | None = Field(default=None, description="Regular expression defining valid VRS string patterns")
191
+ """Profile-level extent restrictions per OGC API - EDR Part 3.
192
+
193
+ CRS/TRS/VRS constraints are split into two distinct concerns:
194
+
195
+ - ``extent_crs`` / ``extent_trs`` / ``extent_vrs`` — constrain the single
196
+ CRS/TRS/VRS value used to *express* the extent (``extent.spatial.crs``,
197
+ ``extent.temporal.trs``, ``extent.vertical.vrs``). Typically this is
198
+ just CRS84 / Gregorian.
199
+
200
+ - ``supported_crs`` / ``supported_trs`` / ``supported_vrs`` — constrain
201
+ the list of CRS/TRS/VRS values the service *supports* for queries
202
+ (the top-level ``crs`` array and ``data_queries.*.variables.crs_details``).
203
+ This is usually broader than the extent CRS.
204
+ """
205
+ minimum_bbox: list[float] = Field(
206
+ min_length=4, max_length=4,
207
+ description="Minimum spatial bounds [minLon, minLat, maxLon, maxLat]",
208
+ )
209
+
210
+ # --- Extent CRS/TRS/VRS (single value on extent object) ---
211
+ extent_crs: CrsConstraint | None = Field(
212
+ default=None,
213
+ description="Constraint on extent.spatial.crs — the CRS the extent is expressed in",
214
+ )
215
+ extent_trs: CrsConstraint | None = Field(
216
+ default=None,
217
+ description="Constraint on extent.temporal.trs — the TRS the temporal extent is expressed in",
218
+ )
219
+ extent_vrs: CrsConstraint | None = Field(
220
+ default=None,
221
+ description="Constraint on extent.vertical.vrs — the VRS the vertical extent is expressed in",
222
+ )
223
+
224
+ # --- Supported CRS/TRS/VRS (lists — what the service supports for queries) ---
225
+ supported_crs: CrsConstraint | None = Field(
226
+ default=None,
227
+ description=(
228
+ "Constraint on the top-level crs array and crs_details — "
229
+ "the CRS values the service supports for data queries. "
230
+ "Typically broader than extent_crs."
231
+ ),
232
+ )
233
+ supported_trs: CrsConstraint | None = Field(
234
+ default=None,
235
+ description="Constraint on supported TRS values for data queries",
236
+ )
237
+ supported_vrs: CrsConstraint | None = Field(
238
+ default=None,
239
+ description="Constraint on supported VRS values for data queries",
240
+ )
161
241
 
162
242
  @model_validator(mode="after")
163
243
  def validate_crs_specification(self) -> ExtentRequirements:
164
- if self.allowed_crs is None and self.crs_pattern is None:
165
- raise ValueError("Either allowed_crs or crs_pattern must be specified")
244
+ if self.extent_crs is None and self.supported_crs is None:
245
+ raise ValueError(
246
+ "At least one of 'extent_crs' or 'supported_crs' must be specified"
247
+ )
166
248
  return self
167
249
 
168
250
 
@@ -327,13 +409,11 @@ class ServiceProfile(BaseModel):
327
409
  if not coll.parameter_names:
328
410
  continue
329
411
  for param_name, param in coll.parameter_names.root.items():
330
- # Check if unit is specified
331
412
  if not hasattr(param, 'unit') or param.unit is None:
332
413
  raise ValueError(
333
414
  f"Parameter '{param_name}' in collection '{coll.id}' must specify unit "
334
415
  f"(required by OGC API - EDR Part 3 REQ_parameter-names)"
335
416
  )
336
- # Check if observedProperty is specified (already required by edr-pydantic)
337
417
  if not hasattr(param, 'observedProperty') or param.observedProperty is None:
338
418
  raise ValueError(
339
419
  f"Parameter '{param_name}' in collection '{coll.id}' must specify observedProperty"
@@ -382,69 +462,49 @@ class ServiceProfile(BaseModel):
382
462
 
383
463
  @model_validator(mode="after")
384
464
  def validate_collection_extent_patterns(self) -> ServiceProfile:
385
- """Validate collection CRS/TRS/VRS values against extent_requirements patterns and enums."""
465
+ """Validate collection CRS/TRS/VRS values against extent_requirements constraints."""
386
466
  if not self.extent_requirements:
387
467
  return self
388
468
  er = self.extent_requirements
389
469
 
390
- # Compile patterns once, validating regex syntax
391
- crs_pat = _compile_optional_pattern("crs_pattern", er.crs_pattern)
392
- trs_pat = _compile_optional_pattern("trs_pattern", er.trs_pattern)
393
- vrs_pat = _compile_optional_pattern("vrs_pattern", er.vrs_pattern)
470
+ # Helper: check a single value against a CrsConstraint
471
+ def _check(label: str, value: str, constraint: "CrsConstraint | None") -> None:
472
+ if constraint is None:
473
+ return
474
+ if constraint.allowed is not None and value not in constraint.allowed:
475
+ raise ValueError(
476
+ f"{label} '{value}' is not in allowed list {constraint.allowed}"
477
+ )
478
+ if constraint.pattern is not None:
479
+ pat = _compile_optional_pattern(label, constraint.pattern)
480
+ if pat and not pat.fullmatch(value):
481
+ raise ValueError(
482
+ f"{label} '{value}' does not match pattern '{constraint.pattern}'"
483
+ )
394
484
 
395
485
  for coll in self.collections:
396
- # --- CRS ---
486
+ # --- extent.spatial.crs → extent_crs ---
397
487
  crs = coll.extent.spatial.crs if coll.extent and coll.extent.spatial else None
398
488
  if crs:
399
- if er.allowed_crs and crs not in er.allowed_crs:
400
- raise ValueError(
401
- f"Collection '{coll.id}' CRS '{crs}' is not in allowed_crs "
402
- f"{er.allowed_crs}"
403
- )
404
- if crs_pat and not crs_pat.fullmatch(crs):
405
- raise ValueError(
406
- f"Collection '{coll.id}' CRS '{crs}' does not match "
407
- f"crs_pattern '{er.crs_pattern}'"
408
- )
489
+ _check(f"Collection '{coll.id}' extent.spatial.crs", crs, er.extent_crs)
409
490
 
410
- # --- TRS ---
411
- trs = (
412
- coll.extent.temporal.trs
413
- if coll.extent and coll.extent.temporal else None
414
- )
491
+ # --- extent.temporal.trs → extent_trs ---
492
+ trs = coll.extent.temporal.trs if coll.extent and coll.extent.temporal else None
415
493
  if trs:
416
- if er.allowed_trs and trs not in er.allowed_trs:
417
- raise ValueError(
418
- f"Collection '{coll.id}' TRS '{trs}' is not in allowed_trs "
419
- f"{er.allowed_trs}"
420
- )
421
- if trs_pat and not trs_pat.fullmatch(trs):
422
- raise ValueError(
423
- f"Collection '{coll.id}' TRS '{trs}' does not match "
424
- f"trs_pattern '{er.trs_pattern}'"
425
- )
494
+ _check(f"Collection '{coll.id}' extent.temporal.trs", trs, er.extent_trs)
426
495
 
427
- # --- VRS ---
428
- vrs = (
429
- coll.extent.vertical.vrs
430
- if coll.extent and coll.extent.vertical else None
431
- )
496
+ # --- extent.vertical.vrs → extent_vrs ---
497
+ vrs = coll.extent.vertical.vrs if coll.extent and coll.extent.vertical else None
432
498
  if vrs:
433
- if er.allowed_vrs and vrs not in er.allowed_vrs:
434
- raise ValueError(
435
- f"Collection '{coll.id}' VRS '{vrs}' is not in allowed_vrs "
436
- f"{er.allowed_vrs}"
437
- )
438
- if vrs_pat and not vrs_pat.fullmatch(vrs):
439
- raise ValueError(
440
- f"Collection '{coll.id}' VRS '{vrs}' does not match "
441
- f"vrs_pattern '{er.vrs_pattern}'"
442
- )
499
+ _check(f"Collection '{coll.id}' extent.vertical.vrs", vrs, er.extent_vrs)
500
+
501
+ # --- top-level crs array → supported_crs ---
502
+ if coll.crs and er.supported_crs:
503
+ for crs_val in coll.crs:
504
+ _check(f"Collection '{coll.id}' crs[]", crs_val, er.supported_crs)
443
505
 
444
- # --- crs_details in data_queries ---
445
- # Each data query may declare crs_details with per-query CRS support.
446
- # Validate those CRS values against the same allowed_crs / crs_pattern.
447
- if coll.data_queries and (er.allowed_crs or crs_pat):
506
+ # --- crs_details in data_queries → supported_crs ---
507
+ if coll.data_queries and er.supported_crs:
448
508
  for qt_name, qt_val in coll.data_queries:
449
509
  if qt_val is None:
450
510
  continue
@@ -455,19 +515,11 @@ class ServiceProfile(BaseModel):
455
515
  )
456
516
  for entry in (crs_details or []):
457
517
  dq_crs = entry.get("crs") if isinstance(entry, dict) else None
458
- if not dq_crs:
459
- continue
460
- if er.allowed_crs and dq_crs not in er.allowed_crs:
461
- raise ValueError(
462
- f"Collection '{coll.id}' data query '{qt_name}' "
463
- f"crs_details CRS '{dq_crs}' is not in allowed_crs "
464
- f"{er.allowed_crs}"
465
- )
466
- if crs_pat and not crs_pat.fullmatch(dq_crs):
467
- raise ValueError(
468
- f"Collection '{coll.id}' data query '{qt_name}' "
469
- f"crs_details CRS '{dq_crs}' does not match "
470
- f"crs_pattern '{er.crs_pattern}'"
518
+ if dq_crs:
519
+ _check(
520
+ f"Collection '{coll.id}' data_queries.{qt_name}.crs_details",
521
+ dq_crs,
522
+ er.supported_crs,
471
523
  )
472
524
  return self
473
525
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 2.4.0
3
+ Version: 3.0.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
@@ -234,18 +234,27 @@ keywords:
234
234
  - my-parameter
235
235
  - my-domain
236
236
 
237
+ # ── Profile-level constraints ─────────────────────────────────────────────
238
+ # Define what any conforming implementation MUST support.
239
+ # Validated at build time and embedded in the generated OpenAPI as
240
+ # enum/pattern constraints so runtime tools can enforce them.
237
241
  required_conformance_classes:
238
242
  - "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core"
239
243
 
240
244
  extent_requirements:
241
245
  minimum_bbox: [-180, -90, 180, 90]
242
- allowed_crs:
243
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
246
+ extent_crs:
247
+ allowed:
248
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
244
249
 
245
250
  output_formats:
246
251
  - name: GeoJSON
247
252
  media_type: application/geo+json
248
253
 
254
+ # ── Reference metadata ────────────────────────────────────────────────────
255
+ # Describes the structure of collections a conforming service should expose.
256
+ # For dynamic fields (extent intervals, custom dimension values), use the
257
+ # full possible range — not a snapshot of current live data.
249
258
  collections:
250
259
  - id: my_collection
251
260
  links:
@@ -264,6 +273,11 @@ collections:
264
273
  unit:
265
274
  label: Celsius
266
275
  symbol: "°C"
276
+
277
+ # ── Normative requirements and abstract tests ─────────────────────────────
278
+ # Drive the AsciiDoc/PDF output. Tests must reference a valid requirement id.
279
+ requirements: []
280
+ abstract_tests: []
267
281
  ```
268
282
 
269
283
  ### Top-level fields
@@ -297,6 +311,16 @@ collections:
297
311
 
298
312
  Uses the [edr-pydantic](https://github.com/KNMI/edr-pydantic) `Collection` model — the same schema an EDR server returns at `/collections/{id}`.
299
313
 
314
+ > **Service-level vs collection-level fields**
315
+ >
316
+ > The profile has two distinct levels that serve different purposes — they don't merge or override each other:
317
+ >
318
+ > - **Service-level fields** (`extent_requirements`, `parameter_schema`, `parameter_name_pattern`, `collection_id_pattern`) define *constraints* that any conforming implementation must satisfy. They are validated at build time and embedded in the generated OpenAPI as schema constraints (enums, patterns) so runtime tools can enforce them against a live server.
319
+ >
320
+ > - **`collections[]`** provides *reference metadata* — the structure and representative values of collections a conforming service should expose. This mirrors what an EDR server returns at `/collections/{id}` and drives the generated OpenAPI paths and response schemas.
321
+ >
322
+ > For fields that are inherently dynamic (e.g. `extent.temporal.interval`, `extent.custom[].values`), use representative values that describe the *full possible range*, not a snapshot of current live data. For example, use an open-ended temporal interval `["2020-01-01T00:00:00Z", null]` rather than a specific end date, and list all valid custom dimension values rather than only those currently in the dataset. The `validate-server` command tests API structure and response schema conformance — it does not assert that specific data values in responses match the profile config exactly.
323
+
300
324
  | Field | Required | Description |
301
325
  |---|---|---|
302
326
  | `id` | yes | Collection identifier |
@@ -363,25 +387,41 @@ parameter_names:
363
387
 
364
388
  ### `extent_requirements`
365
389
 
366
- Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI.
390
+ Constrains CRS, TRS, and VRS values across all collections. Validated at build time and embedded in the generated OpenAPI as `enum` or `pattern` constraints on the collection response schema.
391
+
392
+ These are **profile-level constraints** — they define what any conforming implementation must support, independent of the specific values a live service happens to have at a given moment.
393
+
394
+ CRS/TRS/VRS constraints are split into two distinct concerns:
395
+
396
+ - `extent_crs` / `extent_trs` / `extent_vrs` — constrain the **single value** used to express the extent (`extent.spatial.crs`, `extent.temporal.trs`, `extent.vertical.vrs`). Typically just CRS84 / Gregorian.
397
+ - `supported_crs` / `supported_trs` / `supported_vrs` — constrain the **list of values** the service supports for queries (the top-level `crs` array and `data_queries.*.variables.crs_details`). Usually broader than the extent constraint.
398
+
399
+ Each constraint uses either `allowed` (exact list) or `pattern` (regex) — not both.
367
400
 
368
401
  ```yaml
369
402
  extent_requirements:
370
403
  minimum_bbox: [-180, -90, 180, 90]
371
404
 
372
- # Option A: exact list
373
- allowed_crs:
374
- - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
375
- - "http://www.opengis.net/def/crs/EPSG/0/4326"
376
-
377
- # Option B: regex (accepts any OGC or EPSG CRS)
378
- # crs_pattern: "^http://www\\.opengis\\.net/def/crs/(OGC|EPSG)/.*$"
379
-
380
- allowed_trs:
381
- - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
405
+ # Extent is always expressed in CRS84
406
+ extent_crs:
407
+ allowed:
408
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
409
+
410
+ # Service supports CRS84 plus any EPSG CRS for queries
411
+ supported_crs:
412
+ pattern: "^(http://www\\.opengis\\.net/def/crs/OGC/1\\.3/CRS84|http://www\\.opengis\\.net/def/crs/EPSG/.*)$"
413
+ # or an explicit list:
414
+ # supported_crs:
415
+ # allowed:
416
+ # - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
417
+ # - "http://www.opengis.net/def/crs/EPSG/0/4326"
418
+
419
+ extent_trs:
420
+ allowed:
421
+ - "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian"
382
422
  ```
383
423
 
384
- Either `allowed_crs` or `crs_pattern` is required. The same enum/regex approach applies to `allowed_trs`/`trs_pattern` and `allowed_vrs`/`vrs_pattern`.
424
+ At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
385
425
 
386
426
  ---
387
427