oapi-profile-builder 2.5.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.5.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.0.0}/PKG-INFO +55 -15
  2. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/README.md +54 -14
  3. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/generate.py +25 -20
  5. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/models.py +112 -73
  6. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0/src/oapi_profile_builder.egg-info}/PKG-INFO +55 -15
  7. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/LICENSE +0 -0
  8. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/setup.cfg +0 -0
  9. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/__init__.py +0 -0
  10. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cite.py +0 -0
  11. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cite_features.py +0 -0
  12. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/cli.py +0 -0
  13. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/compile.py +0 -0
  14. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder/server_validation.py +0 -0
  15. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  16. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  17. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  18. {oapi_profile_builder-2.5.0 → oapi_profile_builder-3.0.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  19. {oapi_profile_builder-2.5.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.5.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.5.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
  },
@@ -164,20 +164,87 @@ class SubscriptionFilter(BaseModel):
164
164
  type: FilterType = FilterType.string
165
165
 
166
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
+
167
190
  class ExtentRequirements(BaseModel):
168
- """Profile-level extent restrictions per OGC API - EDR Part 3."""
169
- minimum_bbox: list[float] = Field(min_length=4, max_length=4, description="Minimum spatial bounds [minLon, minLat, maxLon, maxLat]")
170
- allowed_crs: list[str] | None = Field(default=None, description="Enumerated list of valid CRS values")
171
- crs_pattern: str | None = Field(default=None, description="Regular expression defining valid CRS string patterns")
172
- allowed_trs: list[str] | None = Field(default=None, description="Enumerated list of valid TRS values")
173
- trs_pattern: str | None = Field(default=None, description="Regular expression defining valid TRS string patterns")
174
- allowed_vrs: list[str] | None = Field(default=None, description="Enumerated list of valid VRS values")
175
- 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
+ )
176
241
 
177
242
  @model_validator(mode="after")
178
243
  def validate_crs_specification(self) -> ExtentRequirements:
179
- if self.allowed_crs is None and self.crs_pattern is None:
180
- 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
+ )
181
248
  return self
182
249
 
183
250
 
@@ -395,69 +462,49 @@ class ServiceProfile(BaseModel):
395
462
 
396
463
  @model_validator(mode="after")
397
464
  def validate_collection_extent_patterns(self) -> ServiceProfile:
398
- """Validate collection CRS/TRS/VRS values against extent_requirements patterns and enums."""
465
+ """Validate collection CRS/TRS/VRS values against extent_requirements constraints."""
399
466
  if not self.extent_requirements:
400
467
  return self
401
468
  er = self.extent_requirements
402
469
 
403
- # Compile patterns once, validating regex syntax
404
- crs_pat = _compile_optional_pattern("crs_pattern", er.crs_pattern)
405
- trs_pat = _compile_optional_pattern("trs_pattern", er.trs_pattern)
406
- 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
+ )
407
484
 
408
485
  for coll in self.collections:
409
- # --- CRS ---
486
+ # --- extent.spatial.crs → extent_crs ---
410
487
  crs = coll.extent.spatial.crs if coll.extent and coll.extent.spatial else None
411
488
  if crs:
412
- if er.allowed_crs and crs not in er.allowed_crs:
413
- raise ValueError(
414
- f"Collection '{coll.id}' CRS '{crs}' is not in allowed_crs "
415
- f"{er.allowed_crs}"
416
- )
417
- if crs_pat and not crs_pat.fullmatch(crs):
418
- raise ValueError(
419
- f"Collection '{coll.id}' CRS '{crs}' does not match "
420
- f"crs_pattern '{er.crs_pattern}'"
421
- )
489
+ _check(f"Collection '{coll.id}' extent.spatial.crs", crs, er.extent_crs)
422
490
 
423
- # --- TRS ---
424
- trs = (
425
- coll.extent.temporal.trs
426
- if coll.extent and coll.extent.temporal else None
427
- )
491
+ # --- extent.temporal.trs → extent_trs ---
492
+ trs = coll.extent.temporal.trs if coll.extent and coll.extent.temporal else None
428
493
  if trs:
429
- if er.allowed_trs and trs not in er.allowed_trs:
430
- raise ValueError(
431
- f"Collection '{coll.id}' TRS '{trs}' is not in allowed_trs "
432
- f"{er.allowed_trs}"
433
- )
434
- if trs_pat and not trs_pat.fullmatch(trs):
435
- raise ValueError(
436
- f"Collection '{coll.id}' TRS '{trs}' does not match "
437
- f"trs_pattern '{er.trs_pattern}'"
438
- )
494
+ _check(f"Collection '{coll.id}' extent.temporal.trs", trs, er.extent_trs)
439
495
 
440
- # --- VRS ---
441
- vrs = (
442
- coll.extent.vertical.vrs
443
- if coll.extent and coll.extent.vertical else None
444
- )
496
+ # --- extent.vertical.vrs → extent_vrs ---
497
+ vrs = coll.extent.vertical.vrs if coll.extent and coll.extent.vertical else None
445
498
  if vrs:
446
- if er.allowed_vrs and vrs not in er.allowed_vrs:
447
- raise ValueError(
448
- f"Collection '{coll.id}' VRS '{vrs}' is not in allowed_vrs "
449
- f"{er.allowed_vrs}"
450
- )
451
- if vrs_pat and not vrs_pat.fullmatch(vrs):
452
- raise ValueError(
453
- f"Collection '{coll.id}' VRS '{vrs}' does not match "
454
- f"vrs_pattern '{er.vrs_pattern}'"
455
- )
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)
456
505
 
457
- # --- crs_details in data_queries ---
458
- # Each data query may declare crs_details with per-query CRS support.
459
- # Validate those CRS values against the same allowed_crs / crs_pattern.
460
- 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:
461
508
  for qt_name, qt_val in coll.data_queries:
462
509
  if qt_val is None:
463
510
  continue
@@ -468,19 +515,11 @@ class ServiceProfile(BaseModel):
468
515
  )
469
516
  for entry in (crs_details or []):
470
517
  dq_crs = entry.get("crs") if isinstance(entry, dict) else None
471
- if not dq_crs:
472
- continue
473
- if er.allowed_crs and dq_crs not in er.allowed_crs:
474
- raise ValueError(
475
- f"Collection '{coll.id}' data query '{qt_name}' "
476
- f"crs_details CRS '{dq_crs}' is not in allowed_crs "
477
- f"{er.allowed_crs}"
478
- )
479
- if crs_pat and not crs_pat.fullmatch(dq_crs):
480
- raise ValueError(
481
- f"Collection '{coll.id}' data query '{qt_name}' "
482
- f"crs_details CRS '{dq_crs}' does not match "
483
- 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,
484
523
  )
485
524
  return self
486
525
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 2.5.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