oapi-profile-builder 3.6.2__tar.gz → 3.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {oapi_profile_builder-3.6.2/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.7.0}/PKG-INFO +1 -1
  2. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/pyproject.toml +1 -1
  3. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/generate.py +303 -347
  4. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0/src/oapi_profile_builder.egg-info}/PKG-INFO +1 -1
  5. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/LICENSE +0 -0
  6. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/README.md +0 -0
  7. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/setup.cfg +0 -0
  8. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/__init__.py +0 -0
  9. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cite.py +0 -0
  10. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cite_features.py +0 -0
  11. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cli.py +0 -0
  12. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/compile.py +0 -0
  13. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cover.py +0 -0
  14. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/models.py +0 -0
  15. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.6.2 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.6.2
3
+ Version: 3.7.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.6.2"
7
+ version = "3.7.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -37,7 +37,7 @@ from __future__ import annotations
37
37
 
38
38
  from pathlib import Path
39
39
 
40
- import yaml
40
+ import yaml, copy
41
41
 
42
42
  from edr_pydantic.collections import Collection
43
43
  from oapi_profile_builder.models import ServiceProfile
@@ -79,6 +79,7 @@ _LANG = {"$ref": "#/components/parameters/lang"}
79
79
  _DATETIME = {"$ref": f"{_FEATURES}#/components/parameters/datetime"}
80
80
  _PARAM_NAME = {"$ref": f"{_EDR}/parameters/parameter-name.yaml"}
81
81
  _Z = {"$ref": f"{_EDR}/parameters/z.yaml"}
82
+ _CRS = {"$ref": f"{_EDR}/parameters/crs.yaml"}
82
83
 
83
84
  # Error responses follow RFC 9457 (Problem Details for HTTP APIs), served as
84
85
  # application/problem+json, per OGC API - EDR Part 3 error-handling requirement
@@ -135,45 +136,71 @@ _LINK_SCHEMA = {
135
136
  _LINKS_ARRAY = {"type": "array", "items": _LINK_SCHEMA}
136
137
 
137
138
  # Landing page response with required profile link per REQ_publishing
138
- def _landing_page_schema(profile_uri: str, version: str, title: str = "", description: str = "", keywords: list | None = None) -> dict:
139
+ def _landing_page_schema(profile: "ServiceProfile") -> dict:
140
+ """Build a 200 response schema for the landing page with full metadata."""
141
+ version = profile.openapi_version
142
+ profile_uri = profile.req_uri
143
+
144
+ # Recommended link types for OGC API - EDR landing page
145
+ link_rels = ["self", "service-desc", "service-doc", "conformance", "data", "profile"]
146
+ link_items = {
147
+ "type": "object",
148
+ "required": ["href", "rel"],
149
+ "properties": {
150
+ "href": {"type": "string"},
151
+ "rel": {"type": "string", "enum": link_rels},
152
+ "type": {"type": "string"},
153
+ "title": {"type": "string"}
154
+ }
155
+ }
156
+
139
157
  schema_props: dict = {
140
- "links": {
141
- "type": "array",
142
- "items": _LINK_SCHEMA,
143
- **(_contains({
144
- "type": "object",
145
- "required": ["href", "rel"],
146
- "properties": {
147
- "rel": _const("profile", version),
148
- "href": _const(profile_uri, version),
149
- },
150
- }, version) if version == "3.1.0" else {}),
151
- },
158
+ "links": {"type": "array", "items": link_items},
159
+ "title": {"type": "string", **_const(profile.title, version)},
160
+ "version": {"type": "string", **_const(profile.version, version)},
161
+ "x-ogc-profile": {"type": "string", **_const(profile_uri, version)}
152
162
  }
153
- if title:
154
- schema_props["title"] = {"type": "string", **_const(title, version)}
155
- if description:
156
- schema_props["description"] = {"type": "string"}
157
- if keywords:
158
- schema_props["keywords"] = {
159
- "type": "array",
160
- "items": {"type": "string"},
161
- **(_contains({"enum": keywords}, version) if version == "3.1.0" else {"items": {"type": "string", "enum": keywords}}),
163
+
164
+ if profile.description:
165
+ schema_props["description"] = {"type": "string", **_const(profile.description, version)}
166
+ if profile.keywords:
167
+ schema_props["keywords"] = {"type": "array", "items": {"type": "string", "enum": profile.keywords}}
168
+
169
+ if profile.provider:
170
+ p = profile.provider
171
+ provider_props = {"name": {"type": "string", **_const(p.name, version)}}
172
+ if p.url: provider_props["url"] = {"type": "string", **_const(p.url, version)}
173
+ if p.contact:
174
+ c = p.contact
175
+ contact_props = {}
176
+ if c.email: contact_props["email"] = {"type": "string", **_const(c.email, version)}
177
+ for key in ("phone", "hours", "instructions", "address", "postalcode", "city", "country"):
178
+ val = getattr(c, key, None)
179
+ if val: contact_props[f"x-{key}"] = {"type": "string", **_const(val, version)}
180
+ if contact_props: provider_props["contact"] = {"type": "object", "properties": contact_props}
181
+ schema_props["provider"] = {"type": "object", "properties": provider_props}
182
+
183
+ if profile.classification:
184
+ cl = profile.classification
185
+ schema_props["x-classification"] = {
186
+ "type": "object",
187
+ "properties": {
188
+ "level": {"type": "string", **_const(cl.level, version)},
189
+ "system": {"type": "string", **_const(cl.system, version)} if cl.system else {"type": "string"}
190
+ }
162
191
  }
163
192
 
193
+ if profile.metadata_date:
194
+ schema_props["x-metadata-date"] = {"type": "string", **_const(profile.metadata_date, version)}
195
+ if profile.resource_service_publish_date:
196
+ schema_props["x-resource-publish-date"] = {"type": "string", **_const(profile.resource_service_publish_date, version)}
197
+
164
198
  return {
165
199
  "description": "Landing page",
166
- "content": {
167
- "application/json": {
168
- "schema": {
169
- "type": "object",
170
- "required": ["links"],
171
- "properties": schema_props,
172
- }
173
- }
174
- },
200
+ "content": {"application/json": {"schema": {"type": "object", "required": ["links"], "properties": schema_props}}}
175
201
  }
176
202
 
203
+
177
204
  _R200_CONFORMANCE = {
178
205
  "description": "Conformance classes",
179
206
  "content": {
@@ -223,6 +250,7 @@ def _locations_response_schema(profile: "ServiceProfile | None") -> dict:
223
250
  properties named in ``profile.locations_feature_required_properties`` are
224
251
  marked required so the generated OpenAPI enforces them.
225
252
  """
253
+ version = profile.openapi_version if profile else "3.0.0"
226
254
  required_props = list(getattr(profile, "locations_feature_required_properties", None) or [])
227
255
  properties_schema: dict = {
228
256
  "type": "object",
@@ -271,6 +299,8 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
271
299
  coll_formats: list[str] = []
272
300
  if coll.output_formats:
273
301
  coll_formats = list(coll.output_formats)
302
+ elif profile and profile.output_formats:
303
+ coll_formats = [f.name for f in profile.output_formats]
274
304
 
275
305
  content: dict = {}
276
306
  if coll_formats:
@@ -278,15 +308,8 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
278
308
  schema_ref = fmt_map.get(fmt_name)
279
309
  if fmt_name == "CoverageJSON":
280
310
  media_type = "application/prs.coverage+json"
281
- # Also accept the newer media type
282
- alt_media = "application/vnd.cov+json"
283
- if schema_ref:
284
- schema = {"$ref": schema_ref}
285
- else:
286
- # Fallback to an inline object if external ref is problematic
287
- schema = {"type": "object"}
311
+ schema = {"$ref": schema_ref} if schema_ref else {"type": "object"}
288
312
  content[media_type] = {"schema": schema}
289
- content[alt_media] = {"schema": schema}
290
313
  elif fmt_name == "GeoJSON":
291
314
  media_type = "application/geo+json"
292
315
  schema = {"$ref": schema_ref} if schema_ref else {"type": "object"}
@@ -301,11 +324,11 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
301
324
  schema = {"$ref": pf.schema_ref} if pf.schema_ref else {"type": "object"}
302
325
  content[pf.media_type] = {"schema": schema}
303
326
  break
304
- else:
305
- # Fallback: CoverageJSON only
306
- content["application/prs.coverage+json"] = {
307
- "schema": {"$ref": f"{_EDR}/schemas/coverageJSON.yaml"}
308
- }
327
+
328
+ if not content:
329
+ # Fallback: GeoJSON and CoverageJSON
330
+ content["application/geo+json"] = {"schema": {"type": "object"}}
331
+ content["application/prs.coverage+json"] = {"schema": {"type": "object"}}
309
332
 
310
333
  # Per the EDR error-handling requirement, a query whose spatio-temporal
311
334
  # selection falls inside the collection extent but matches no data returns
@@ -330,8 +353,9 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
330
353
  "required": ["coords"],
331
354
  "properties": {
332
355
  "coords": {"type": "string", "description": "WKT Point geometry (e.g. POINT(0 51.5))"},
333
- "datetime": {"type": "string", "description": "RFC 3339 datetime or interval (e.g. 2026-01-01T00:00:00Z/2026-12-31T23:59:59Z)"},
356
+ "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
334
357
  "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
358
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
335
359
  "z": {"type": "string", "description": "Vertical level(s)"},
336
360
  "f": {"type": "string", "description": "Response format"},
337
361
  },
@@ -343,6 +367,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
343
367
  "coords": {"type": "string", "description": "WKT Polygon geometry"},
344
368
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
345
369
  "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
370
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
346
371
  "z": {"type": "string", "description": "Vertical level(s)"},
347
372
  "f": {"type": "string", "description": "Response format"},
348
373
  },
@@ -356,6 +381,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
356
381
  "within-units": {"type": "string", "description": "Units for the within parameter (e.g. km)"},
357
382
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
358
383
  "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
384
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
359
385
  "z": {"type": "string", "description": "Vertical level(s)"},
360
386
  "f": {"type": "string", "description": "Response format"},
361
387
  },
@@ -373,6 +399,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
373
399
  },
374
400
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
375
401
  "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
402
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
376
403
  "z": {"type": "string", "description": "Vertical level(s)"},
377
404
  "f": {"type": "string", "description": "Response format"},
378
405
  },
@@ -384,6 +411,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
384
411
  "coords": {"type": "string", "description": "WKT LineString geometry"},
385
412
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
386
413
  "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
414
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
387
415
  "z": {"type": "string", "description": "Vertical level(s)"},
388
416
  "f": {"type": "string", "description": "Response format"},
389
417
  },
@@ -396,41 +424,22 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
396
424
  "corridor-width": {"type": "number", "description": "Width of the corridor"},
397
425
  "corridor-height": {"type": "number", "description": "Height of the corridor"},
398
426
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
399
- "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
400
- "z": {"type": "string", "description": "Vertical level(s)"},
427
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
401
428
  "f": {"type": "string", "description": "Response format"},
402
429
  },
403
- },
404
- "items": {
405
- "type": "object",
406
- "properties": {
407
- "bbox": {"type": "string", "description": "Bounding box filter"},
408
- "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
409
- "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
410
- "z": {"type": "string", "description": "Vertical level(s)"},
411
- "f": {"type": "string", "description": "Response format"},
412
- },
413
- },
414
- "locations": {
415
- "type": "object",
416
- "properties": {
417
- "bbox": {
418
- "type": "array",
419
- "description": "Bounding box [minLon, minLat, maxLon, maxLat]",
420
- "items": {"type": "number"},
421
- "minItems": 4,
422
- "maxItems": 6,
423
- },
424
- "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
425
- "f": {"type": "string", "description": "Response format"},
426
- },
427
- },
428
430
  }
429
-
430
-
431
- def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict) -> dict:
431
+ },
432
+ def _post_operation(get_op: dict, query_type: str, coll: object, responses: dict) -> dict:
432
433
  """Build a POST operation mirroring a GET EDR data query operation."""
433
434
  body_schema = _POST_BODY_SCHEMAS.get(query_type, {"type": "object"})
435
+ import copy
436
+ body_schema = copy.deepcopy(body_schema)
437
+
438
+ # Filter out 'z' if collection has no vertical extent
439
+ if hasattr(coll, 'extent') and coll.extent and not coll.extent.vertical:
440
+ if "properties" in body_schema and "z" in body_schema["properties"]:
441
+ del body_schema["properties"]["z"]
442
+
434
443
  # Carry over a default output format from the GET operation's `f` parameter
435
444
  # so the POST body documents the same default.
436
445
  default_f = None
@@ -439,8 +448,6 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
439
448
  default_f = p.get("schema", {}).get("default")
440
449
  break
441
450
  if default_f and "properties" in body_schema and "f" in body_schema["properties"]:
442
- import copy
443
- body_schema = copy.deepcopy(body_schema)
444
451
  body_schema["properties"]["f"]["default"] = default_f
445
452
  return {
446
453
  "summary": get_op["summary"].replace("query", "query (POST)"),
@@ -464,35 +471,35 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
464
471
  _QUERY_PARAMS: dict[str, list[dict]] = {
465
472
  "position": [
466
473
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
467
- _DATETIME, _PARAM_NAME, _Z, _F,
474
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
468
475
  ],
469
476
  "area": [
470
477
  {"$ref": f"{_EDR}/parameters/areaCoords.yaml"},
471
- _DATETIME, _PARAM_NAME, _Z, _F,
478
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
472
479
  ],
473
480
  "radius": [
474
481
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
475
482
  {"$ref": f"{_EDR}/parameters/within.yaml"},
476
483
  {"name": "within-units", "in": "query", "required": True, "description": "Units for the within parameter e.g. km", "schema": {"type": "string"}},
477
- _DATETIME, _PARAM_NAME, _Z, _F,
484
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
478
485
  ],
479
486
  "cube": [
480
487
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
481
- _DATETIME, _PARAM_NAME, _Z, _F,
488
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
482
489
  ],
483
490
  "trajectory": [
484
491
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
485
- _DATETIME, _PARAM_NAME, _Z, _F,
492
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
486
493
  ],
487
494
  "corridor": [
488
495
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
489
496
  {"name": "corridor-width", "in": "query", "required": True, "description": "Width of the corridor", "schema": {"type": "number"}},
490
497
  {"name": "corridor-height", "in": "query", "description": "Height of the corridor", "schema": {"type": "number"}},
491
- _DATETIME, _PARAM_NAME, _Z, _F,
498
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
492
499
  ],
493
500
  "items": [
494
501
  {"name": "bbox", "in": "query", "description": "Bounding box filter", "schema": {"type": "string"}},
495
- _DATETIME, _PARAM_NAME, _Z, _F,
502
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
496
503
  ],
497
504
  "locations": [
498
505
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
@@ -501,257 +508,14 @@ _QUERY_PARAMS: dict[str, list[dict]] = {
501
508
  "instances": [_F],
502
509
  }
503
510
 
504
-
505
511
  def _operation_id(*parts: str) -> str:
506
- """Build a valid operationId (letters, digits, underscores only).
507
-
508
- Each part is sanitized by splitting on non-alphanumeric characters,
509
- capitalizing the first letter of each word while preserving the rest,
510
- then joining everything together.
511
- """
512
512
  import re as _re
513
-
514
513
  def sanitize(s: str) -> str:
515
514
  words = _re.split(r"[^a-zA-Z0-9]+", s)
516
515
  return "".join((w[0].upper() + w[1:]) for w in words if w)
517
-
518
516
  return "".join(sanitize(p) for p in parts)
519
517
 
520
518
 
521
- def _collection_response_schema(coll: Collection,
522
- profile: "ServiceProfile | None") -> dict:
523
- """Build a 200 response schema for a single collection endpoint.
524
-
525
- Includes:
526
- - CRS constraints from extent_requirements (enum or pattern)
527
- - TRS/VRS constraints from extent_requirements
528
- - Full temporal extent schema (interval, values, trs)
529
- - Collection-level crs array
530
- - Full parameter_names objects with all fields
531
- - parameter_name_pattern as propertyNames constraint
532
- """
533
- version = profile.openapi_version if profile else "3.0.0"
534
- crs_schema: dict = {"type": "string"} # extent.spatial.crs
535
- trs_schema: dict = {"type": "string"} # extent.temporal.trs
536
- vrs_schema: dict = {"type": "string"} # extent.vertical.vrs
537
- supported_crs_schema: dict = {"type": "string"} # crs[] and crs_details[].crs
538
-
539
- if profile and profile.extent_requirements:
540
- er = profile.extent_requirements
541
-
542
- def _apply(schema: dict, constraint: "object | None") -> None:
543
- if constraint is None:
544
- return
545
- if getattr(constraint, "allowed", None):
546
- schema["enum"] = constraint.allowed
547
- elif getattr(constraint, "pattern", None):
548
- schema["pattern"] = constraint.pattern
549
-
550
- _apply(crs_schema, er.extent_crs)
551
- _apply(trs_schema, er.extent_trs)
552
- _apply(vrs_schema, er.extent_vrs)
553
- _apply(supported_crs_schema, er.supported_crs)
554
-
555
- # Build parameter_names schema. Precedence: per-collection parameter_schema
556
- # override → profile-level parameter_schema → default EDR Parameter schema.
557
- coll_param_schema = getattr(coll, "parameter_schema", None)
558
- if coll_param_schema:
559
- import copy
560
- param_item_schema = copy.deepcopy(coll_param_schema)
561
- elif profile and profile.parameter_schema:
562
- # Deep-copy so we don't mutate the profile model
563
- import copy
564
- param_item_schema = copy.deepcopy(profile.parameter_schema)
565
- else:
566
- param_item_schema = _parameter_schema(profile.openapi_version)
567
-
568
- param_names_schema: dict = {
569
- "type": "object",
570
- "additionalProperties": param_item_schema,
571
- }
572
- if coll.parameter_names:
573
- param_names_schema["properties"] = {
574
- name: param_item_schema for name in coll.parameter_names.root
575
- }
576
- if version == "3.1.0" and profile and profile.parameter_name_pattern:
577
- param_names_schema["propertyNames"] = {
578
- "type": "string",
579
- "pattern": profile.parameter_name_pattern,
580
- }
581
-
582
- # Build output_formats enum from collection's declared formats
583
- output_formats_schema: dict = {"type": "array", "items": {"type": "string"}}
584
- if coll.output_formats:
585
- output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
586
-
587
- # Build crs array schema — use collection-level crs list if present,
588
- # otherwise apply supported_crs constraint from extent_requirements.
589
- # supported_crs is distinct from extent_crs: it constrains what the
590
- # service supports for queries, not how the extent itself is expressed.
591
- if coll.crs:
592
- crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
593
- else:
594
- crs_array_schema = {"type": "array", "items": supported_crs_schema}
595
-
596
- schema: dict = {
597
- "type": "object",
598
- "required": ["id", "links", "extent"],
599
- "properties": {
600
- "id": {"type": "string"},
601
- "title": {"type": "string"},
602
- "description": {"type": "string"},
603
- "keywords": {"type": "array", "items": {"type": "string"}},
604
- "links": _LINKS_ARRAY,
605
- "crs": crs_array_schema,
606
- "output_formats": output_formats_schema,
607
- "parameter_names": param_names_schema,
608
- "extent": {
609
- "type": "object",
610
- "required": ["spatial"],
611
- "properties": {
612
- "spatial": {
613
- "type": "object",
614
- "required": ["bbox", "crs"],
615
- "properties": {
616
- "bbox": {
617
- "type": "array",
618
- "items": {
619
- "type": "array",
620
- "items": {"type": "number"},
621
- "minItems": 4,
622
- "maxItems": 6,
623
- },
624
- },
625
- "crs": crs_schema,
626
- },
627
- },
628
- "temporal": {
629
- "type": "object",
630
- "properties": {
631
- "interval": {
632
- "type": "array",
633
- "items": {
634
- "type": "array",
635
- "items": {"type": "string", "format": "date-time", "nullable": True} if version == "3.0.0" else {
636
- "oneOf": [
637
- {"type": "string", "format": "date-time"},
638
- {"type": "null"},
639
- ]
640
- },
641
- "minItems": 2,
642
- "maxItems": 2,
643
- },
644
- },
645
- "values": {
646
- "type": "array",
647
- "items": {"type": "string"},
648
- },
649
- "trs": trs_schema,
650
- },
651
- },
652
- "vertical": {
653
- "type": "object",
654
- "properties": {
655
- "interval": {
656
- "type": "array",
657
- "items": {
658
- "type": "array",
659
- "items": {"type": "string"},
660
- },
661
- },
662
- "values": {
663
- "type": "array",
664
- "items": {"type": "string"},
665
- },
666
- "vrs": vrs_schema,
667
- "positive": {
668
- "type": "string",
669
- "enum": ["up", "down"],
670
- "description": (
671
- "Direction in which vertical extent values increase "
672
- "(CF Conventions 'positive' attribute). Disambiguates "
673
- "interval ordering for VRS such as pressure levels."
674
- ),
675
- },
676
- },
677
- },
678
- "custom": {
679
- "type": "array",
680
- "items": {
681
- "type": "object",
682
- "required": ["id", "interval", "reference"],
683
- "properties": {
684
- "id": {"type": "string"},
685
- "interval": {
686
- "type": "array",
687
- "items": {"type": "string"},
688
- },
689
- "values": {
690
- "type": "array",
691
- "items": {"type": "string"},
692
- },
693
- "reference": {"type": "string"},
694
- },
695
- },
696
- },
697
- },
698
- },
699
- "data_queries": {
700
- "type": "object",
701
- "description": "Available EDR data query types for this collection",
702
- "additionalProperties": {
703
- "type": "object",
704
- "properties": {
705
- "link": {
706
- "type": "object",
707
- "properties": {
708
- "href": {"type": "string"},
709
- "rel": {"type": "string"},
710
- "variables": {
711
- "type": "object",
712
- "properties": {
713
- "query_type": {"type": "string"},
714
- "output_formats": {
715
- "type": "array",
716
- "items": {"type": "string"},
717
- },
718
- "default_output_format": {
719
- "type": "string",
720
- "description": "Default output format for this data query when f is omitted",
721
- },
722
- "crs": {
723
- "type": "array",
724
- "description": "CRS values supported by this data query (shorthand for crs_details)",
725
- "items": {"type": "string"},
726
- },
727
- "crs_details": {
728
- "type": "array",
729
- "description": "CRS values supported by this data query",
730
- "items": {
731
- "type": "object",
732
- "required": ["crs"],
733
- "properties": {
734
- "crs": supported_crs_schema,
735
- "wkt": {"type": "string"},
736
- },
737
- },
738
- },
739
- },
740
- },
741
- },
742
- },
743
- },
744
- },
745
- },
746
- },
747
- }
748
-
749
- return {
750
- "description": "Collection metadata",
751
- "content": {"application/json": {"schema": schema}},
752
- }
753
-
754
-
755
519
  def _parameter_schema(version: str) -> dict:
756
520
  """OpenAPI schema for a single EDR Parameter object."""
757
521
  return {
@@ -806,6 +570,163 @@ def _parameter_schema(version: str) -> dict:
806
570
  }
807
571
 
808
572
 
573
+ def _parameter_instance_schema(param: object, version: str) -> dict:
574
+ """Build a fixed OpenAPI schema for a specific Parameter instance."""
575
+ schema = {
576
+ "type": "object",
577
+ "required": ["type", "observedProperty"],
578
+ "properties": {
579
+ "type": {"type": "string", **_const("Parameter", version)},
580
+ }
581
+ }
582
+ for field in ("id", "label", "description", "data-type"):
583
+ val = getattr(param, field.replace("-", "_"), None)
584
+ if val:
585
+ schema["properties"][field] = {"type": "string", **_const(val, version)}
586
+
587
+ op = getattr(param, "observedProperty", None)
588
+ if op:
589
+ op_props = {"label": {"type": "string", **_const(op.label, version)}}
590
+ if getattr(op, "id", None):
591
+ op_props["id"] = {"type": "string", **_const(op.id, version)}
592
+ if getattr(op, "description", None):
593
+ op_props["description"] = {"type": "string", **_const(op.description, version)}
594
+ schema["properties"]["observedProperty"] = {"type": "object", "properties": op_props, "required": ["label"]}
595
+
596
+ u = getattr(param, "unit", None)
597
+ if u:
598
+ u_props = {}
599
+ if getattr(u, "label", None):
600
+ u_props["label"] = {"type": "string", **_const(u.label, version)}
601
+ symbol = getattr(u, "symbol", None)
602
+ if symbol:
603
+ if isinstance(symbol, str):
604
+ u_props["symbol"] = {"type": "string", **_const(symbol, version)}
605
+ elif hasattr(symbol, "value") and hasattr(symbol, "type"):
606
+ u_props["symbol"] = {
607
+ "type": "object",
608
+ "required": ["value", "type"],
609
+ "properties": {
610
+ "value": {"type": "string", **_const(symbol.value, version)},
611
+ "type": {"type": "string", **_const(symbol.type, version)}
612
+ }
613
+ }
614
+ schema["properties"]["unit"] = {"type": "object", "properties": u_props}
615
+
616
+ return schema
617
+
618
+
619
+ def _collection_response_schema(coll: Collection,
620
+ profile: "ServiceProfile | None") -> dict:
621
+ """Build a 200 response schema for a single collection endpoint."""
622
+ version = profile.openapi_version if profile else "3.0.0"
623
+ crs_schema: dict = {"type": "string"}
624
+ trs_schema: dict = {"type": "string"}
625
+ vrs_schema: dict = {"type": "string"}
626
+ supported_crs_schema: dict = {"type": "string"}
627
+
628
+ if profile and profile.extent_requirements:
629
+ er = profile.extent_requirements
630
+ def _apply(schema: dict, constraint: "object | None") -> None:
631
+ if constraint is None: return
632
+ if getattr(constraint, "allowed", None): schema["enum"] = constraint.allowed
633
+ elif getattr(constraint, "pattern", None): schema["pattern"] = constraint.pattern
634
+ _apply(crs_schema, er.extent_crs)
635
+ _apply(trs_schema, er.extent_trs)
636
+ _apply(vrs_schema, er.extent_vrs)
637
+ _apply(supported_crs_schema, er.supported_crs)
638
+
639
+ coll_param_schema = getattr(coll, "parameter_schema", None)
640
+ if coll_param_schema:
641
+ import copy
642
+ param_item_schema = copy.deepcopy(coll_param_schema)
643
+ elif profile and profile.parameter_schema:
644
+ import copy
645
+ param_item_schema = copy.deepcopy(profile.parameter_schema)
646
+ else:
647
+ param_item_schema = _parameter_schema(profile.openapi_version)
648
+
649
+ param_names_schema: dict = {"type": "object", "additionalProperties": param_item_schema}
650
+ if coll.parameter_names:
651
+ param_names_schema["properties"] = {
652
+ name: _parameter_instance_schema(param, version)
653
+ for name, param in coll.parameter_names.root.items()
654
+ }
655
+ if version == "3.1.0" and profile and profile.parameter_name_pattern:
656
+ param_names_schema["propertyNames"] = {"type": "string", "pattern": profile.parameter_name_pattern}
657
+
658
+ output_formats_schema: dict = {"type": "array", "items": {"type": "string"}}
659
+ if coll.output_formats:
660
+ output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
661
+
662
+ if coll.crs: crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
663
+ else: crs_array_schema = {"type": "array", "items": supported_crs_schema}
664
+
665
+ extent_props: dict = {}
666
+ if coll.extent and coll.extent.spatial:
667
+ s = coll.extent.spatial
668
+ extent_props["spatial"] = {
669
+ "type": "object", "required": ["bbox", "crs"],
670
+ "properties": {
671
+ "bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **_const(s.bbox, version)},
672
+ "crs": {"type": "string", **_const(s.crs, version)}
673
+ }
674
+ }
675
+ if coll.extent and coll.extent.temporal:
676
+ t = coll.extent.temporal
677
+ temporal_props = {"trs": {"type": "string", **_const(t.trs, version)}}
678
+ if t.interval: temporal_props["interval"] = {"type": "array", **_const(t.interval, version)}
679
+ if t.values: temporal_props["values"] = {"type": "array", **_const(t.values, version)}
680
+ extent_props["temporal"] = {"type": "object", "required": ["trs"], "properties": temporal_props}
681
+ if coll.extent and coll.extent.vertical:
682
+ v = coll.extent.vertical
683
+ vertical_props = {"vrs": {"type": "string", **_const(v.vrs, version)}}
684
+ if v.interval: vertical_props["interval"] = {"type": "array", **_const(v.interval, version)}
685
+ if v.values: vertical_props["values"] = {"type": "array", **_const(v.values, version)}
686
+ if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **_const(v.positive, version)}
687
+ extent_props["vertical"] = {"type": "object", "required": ["vrs"], "properties": vertical_props}
688
+ if coll.extent and coll.extent.custom:
689
+ custom_items = [{"type": "object", "required": ["id", "interval", "reference"],
690
+ "properties": {"id": {"type": "string", **_const(c.id, version)},
691
+ "interval": {"type": "array", **_const(c.interval, version)},
692
+ "reference": {"type": "string", **_const(c.reference, version)}}} for c in coll.extent.custom]
693
+ extent_props["custom"] = {"type": "array", "items": {"oneOf": custom_items}}
694
+
695
+ dq_props = {}
696
+ if coll.data_queries:
697
+ for dq_name, dq_val in coll.data_queries:
698
+ if dq_val is None: continue
699
+ vars_props = {}
700
+ vars_obj = dq_val.link.variables
701
+ if vars_obj:
702
+ for f in ("query_type", "default_output_format"):
703
+ v = getattr(vars_obj, f, None);
704
+ if v: vars_props[f] = {"type": "string", **_const(v, version)}
705
+ if getattr(vars_obj, "output_formats", None):
706
+ vars_props["output_formats"] = {"type": "array", "items": {"type": "string", "enum": list(vars_obj.output_formats)}}
707
+ extra = getattr(vars_obj, "model_extra", {}) or {}
708
+ if "crs" in extra: vars_props["crs"] = {"type": "array", "items": {"type": "string"}, **_const(extra["crs"], version)}
709
+ if "crs_details" in extra: vars_props["crs_details"] = {"type": "array", **_const(extra["crs_details"], version)}
710
+ dq_props[dq_name] = {"type": "object", "properties": {"link": {"type": "object", "properties": {
711
+ "href": {"type": "string", **_const(dq_val.link.href, version)},
712
+ "rel": {"type": "string", **_const(dq_val.link.rel, version)},
713
+ "variables": {"type": "object", "properties": vars_props}
714
+ }}}}
715
+
716
+ schema: dict = {
717
+ "type": "object", "required": ["id", "links", "extent"],
718
+ "properties": {
719
+ "id": {"type": "string", **_const(coll.id, version)},
720
+ "title": {"type": "string", **_const(coll.title, version)} if coll.title else {"type": "string"},
721
+ "description": {"type": "string", **_const(coll.description, version)} if coll.description else {"type": "string"},
722
+ "keywords": {"type": "array", "items": {"type": "string", "enum": coll.keywords}} if coll.keywords else {"type": "array"},
723
+ "links": _LINKS_ARRAY, "crs": crs_array_schema, "output_formats": output_formats_schema,
724
+ "parameter_names": param_names_schema,
725
+ "extent": {"type": "object", "required": ["spatial"], "properties": extent_props},
726
+ "data_queries": {"type": "object", "properties": dq_props}
727
+ }
728
+ }
729
+ return {"description": "Collection metadata", "content": {"application/json": {"schema": schema}}}
809
730
  def _f_enum(profile: "ServiceProfile | None") -> list[str]:
810
731
  """Allowed values for the `f` (output format) parameter: media types."""
811
732
  enum = ["json", "html"]
@@ -864,6 +785,40 @@ def _f_param(profile: "ServiceProfile | None", variables: object | None) -> dict
864
785
  }
865
786
 
866
787
 
788
+
789
+ def _params_for_query(params: list[dict], profile: "ServiceProfile | None",
790
+ coll: Collection, variables: object | None) -> list[dict]:
791
+ """Refine query parameters based on collection capabilities (z filtering, crs enum)."""
792
+ import copy
793
+ out = []
794
+ has_vertical = coll.extent and coll.extent.vertical
795
+ supported_crs = list(coll.crs) if coll.crs else []
796
+
797
+ # Check extent_requirements for supported_crs if not explicit on collection
798
+ if not supported_crs and profile and profile.extent_requirements and profile.extent_requirements.supported_crs:
799
+ supported_crs = profile.extent_requirements.supported_crs.allowed or []
800
+
801
+ for p in params:
802
+ if p is _Z and not has_vertical:
803
+ continue
804
+
805
+ # Make a copy if we need to modify (e.g. for f default or crs enum)
806
+ p_eff = p
807
+
808
+ # Handle f parameter default
809
+ if p is _F:
810
+ p_eff = _f_param(profile, variables)
811
+
812
+ # Handle crs parameter enum
813
+ elif p is _CRS and supported_crs:
814
+ p_eff = copy.deepcopy(p)
815
+ if "schema" not in p_eff: p_eff["schema"] = {}
816
+ p_eff["schema"]["type"] = "string"
817
+ p_eff["schema"]["enum"] = supported_crs
818
+
819
+ out.append(p_eff)
820
+ return out
821
+
867
822
  def _params_with_default_f(params: list[dict], profile: "ServiceProfile | None",
868
823
  variables: object | None) -> list[dict]:
869
824
  """Replace the shared `_F` reference in a params list with a query-specific default."""
@@ -978,7 +933,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
978
933
  return qv.link.variables if qv is not None and qv.link else None
979
934
 
980
935
  for qt in active:
981
- params = _params_with_default_f(_QUERY_PARAMS.get(qt, []), profile, _vars(qt))
936
+ params = _params_for_query(_QUERY_PARAMS.get(qt, []), profile, coll, _vars(qt))
982
937
 
983
938
  if qt == "instances":
984
939
  paths[f"{base}/instances"] = {"get": {
@@ -1010,7 +965,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1010
965
  }}
1011
966
  # instance-level query sub-paths — GET + optional POST
1012
967
  for sub_qt in (active - {"instances"}):
1013
- sub_params = _params_with_default_f(_QUERY_PARAMS.get(sub_qt, []), profile, _vars(sub_qt))
968
+ sub_params = _params_for_query(_QUERY_PARAMS.get(sub_qt, []), profile, coll, _vars(sub_qt))
1014
969
  if sub_qt == "items":
1015
970
  sub_params = _with_paging_limit(sub_params, profile)
1016
971
  get_op = {
@@ -1023,7 +978,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1023
978
  }
1024
979
  path_ops = {"get": get_op}
1025
980
  if include_post:
1026
- path_ops["post"] = _post_operation(get_op, sub_qt, coll.id, cov_resp)
981
+ path_ops["post"] = _post_operation(get_op, sub_qt, coll, cov_resp)
1027
982
  paths[f"{base}/instances/{{instanceId}}/{sub_qt}"] = path_ops
1028
983
 
1029
984
  elif qt == "items":
@@ -1038,7 +993,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1038
993
  }
1039
994
  path_ops = {"get": get_op}
1040
995
  if include_post:
1041
- path_ops["post"] = _post_operation(get_op, "items", coll.id, cov_resp)
996
+ path_ops["post"] = _post_operation(get_op, "items", coll, cov_resp)
1042
997
  paths[f"{base}/items"] = path_ops
1043
998
 
1044
999
  elif qt == "locations":
@@ -1057,7 +1012,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1057
1012
  }
1058
1013
  path_ops = {"get": get_locations_op}
1059
1014
  if include_post:
1060
- path_ops["post"] = _post_operation(get_locations_op, "locations", coll.id, locations_responses)
1015
+ path_ops["post"] = _post_operation(get_locations_op, "locations", coll, locations_responses)
1061
1016
  paths[f"{base}/locations"] = path_ops
1062
1017
 
1063
1018
  get_location_op = {
@@ -1073,7 +1028,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1073
1028
  }
1074
1029
  loc_path_ops = {"get": get_location_op}
1075
1030
  if include_post:
1076
- loc_path_ops["post"] = _post_operation(get_location_op, "locations", coll.id, cov_resp)
1031
+ loc_path_ops["post"] = _post_operation(get_location_op, "locations", coll, cov_resp)
1077
1032
  paths[f"{base}/locations/{{locId}}"] = loc_path_ops
1078
1033
 
1079
1034
  else:
@@ -1087,27 +1042,28 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1087
1042
  }
1088
1043
  path_ops = {"get": get_op}
1089
1044
  if include_post:
1090
- path_ops["post"] = _post_operation(get_op, qt, coll.id, cov_resp)
1045
+ path_ops["post"] = _post_operation(get_op, qt, coll, cov_resp)
1091
1046
  paths[f"{base}/{qt}"] = path_ops
1092
1047
 
1093
1048
  return paths
1094
1049
 
1095
1050
 
1096
1051
  def _core_paths(profile: ServiceProfile) -> dict:
1097
- landing_response = _landing_page_schema(
1098
- profile.req_uri, profile.openapi_version,
1099
- title=profile.title,
1100
- description=profile.description or "",
1101
- keywords=profile.keywords or None,
1102
- )
1052
+ version = profile.openapi_version
1053
+ landing_response = _landing_page_schema(profile)
1103
1054
 
1104
1055
  # Conformance response with required conformance classes
1105
- conformance_response = _R200_CONFORMANCE.copy()
1056
+ conformance_response = copy.deepcopy(_R200_CONFORMANCE)
1106
1057
  if profile.required_conformance_classes:
1107
- conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["items"] = {
1108
- "type": "string",
1109
- "enum": profile.required_conformance_classes
1110
- }
1058
+ if version == "3.1.0":
1059
+ conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
1060
+ "enum": profile.required_conformance_classes
1061
+ }
1062
+ else:
1063
+ conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["items"] = {
1064
+ "type": "string",
1065
+ "enum": profile.required_conformance_classes
1066
+ }
1111
1067
 
1112
1068
  return {
1113
1069
  "/": {"get": {
@@ -1134,7 +1090,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
1134
1090
  "parameters": [_F, _LANG],
1135
1091
  "responses": {"200": {"description": "Collections list", "content": {"application/json": {"schema": {"type": "object", "properties": {"links": _LINKS_ARRAY}}}}}, "400": _ERR_400, "500": _ERR_500},
1136
1092
  }},
1137
- "/openapi": {"get": {
1093
+ "/api": {"get": {
1138
1094
  "summary": "OpenAPI definition",
1139
1095
  "description": "Retrieve the OpenAPI definition document for this API.",
1140
1096
  "operationId": "getOpenAPI",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.6.2
3
+ Version: 3.7.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