oapi-profile-builder 3.6.3__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.3/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.7.0}/PKG-INFO +1 -1
  2. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/pyproject.toml +1 -1
  3. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/generate.py +292 -343
  4. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0/src/oapi_profile_builder.egg-info}/PKG-INFO +1 -1
  5. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/LICENSE +0 -0
  6. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/README.md +0 -0
  7. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/setup.cfg +0 -0
  8. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/__init__.py +0 -0
  9. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cite.py +0 -0
  10. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cite_features.py +0 -0
  11. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cli.py +0 -0
  12. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/compile.py +0 -0
  13. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/cover.py +0 -0
  14. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/models.py +0 -0
  15. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.6.3 → 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.3
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.3"
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": {
@@ -272,6 +299,8 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
272
299
  coll_formats: list[str] = []
273
300
  if coll.output_formats:
274
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]
275
304
 
276
305
  content: dict = {}
277
306
  if coll_formats:
@@ -279,15 +308,8 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
279
308
  schema_ref = fmt_map.get(fmt_name)
280
309
  if fmt_name == "CoverageJSON":
281
310
  media_type = "application/prs.coverage+json"
282
- # Also accept the newer media type
283
- alt_media = "application/vnd.cov+json"
284
- if schema_ref:
285
- schema = {"$ref": schema_ref}
286
- else:
287
- # Fallback to an inline object if external ref is problematic
288
- schema = {"type": "object"}
311
+ schema = {"$ref": schema_ref} if schema_ref else {"type": "object"}
289
312
  content[media_type] = {"schema": schema}
290
- content[alt_media] = {"schema": schema}
291
313
  elif fmt_name == "GeoJSON":
292
314
  media_type = "application/geo+json"
293
315
  schema = {"$ref": schema_ref} if schema_ref else {"type": "object"}
@@ -302,11 +324,11 @@ def _coverage_response(coll: Collection, profile: "ServiceProfile | None") -> di
302
324
  schema = {"$ref": pf.schema_ref} if pf.schema_ref else {"type": "object"}
303
325
  content[pf.media_type] = {"schema": schema}
304
326
  break
305
- else:
306
- # Fallback: CoverageJSON only
307
- content["application/prs.coverage+json"] = {
308
- "schema": {"$ref": f"{_EDR}/schemas/coverageJSON.yaml"}
309
- }
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"}}
310
332
 
311
333
  # Per the EDR error-handling requirement, a query whose spatio-temporal
312
334
  # selection falls inside the collection extent but matches no data returns
@@ -331,8 +353,9 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
331
353
  "required": ["coords"],
332
354
  "properties": {
333
355
  "coords": {"type": "string", "description": "WKT Point geometry (e.g. POINT(0 51.5))"},
334
- "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"},
335
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"},
336
359
  "z": {"type": "string", "description": "Vertical level(s)"},
337
360
  "f": {"type": "string", "description": "Response format"},
338
361
  },
@@ -344,6 +367,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
344
367
  "coords": {"type": "string", "description": "WKT Polygon geometry"},
345
368
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
346
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"},
347
371
  "z": {"type": "string", "description": "Vertical level(s)"},
348
372
  "f": {"type": "string", "description": "Response format"},
349
373
  },
@@ -357,6 +381,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
357
381
  "within-units": {"type": "string", "description": "Units for the within parameter (e.g. km)"},
358
382
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
359
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"},
360
385
  "z": {"type": "string", "description": "Vertical level(s)"},
361
386
  "f": {"type": "string", "description": "Response format"},
362
387
  },
@@ -374,6 +399,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
374
399
  },
375
400
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
376
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"},
377
403
  "z": {"type": "string", "description": "Vertical level(s)"},
378
404
  "f": {"type": "string", "description": "Response format"},
379
405
  },
@@ -385,6 +411,7 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
385
411
  "coords": {"type": "string", "description": "WKT LineString geometry"},
386
412
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
387
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"},
388
415
  "z": {"type": "string", "description": "Vertical level(s)"},
389
416
  "f": {"type": "string", "description": "Response format"},
390
417
  },
@@ -397,41 +424,22 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
397
424
  "corridor-width": {"type": "number", "description": "Width of the corridor"},
398
425
  "corridor-height": {"type": "number", "description": "Height of the corridor"},
399
426
  "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
400
- "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
401
- "z": {"type": "string", "description": "Vertical level(s)"},
402
- "f": {"type": "string", "description": "Response format"},
403
- },
404
- },
405
- "items": {
406
- "type": "object",
407
- "properties": {
408
- "bbox": {"type": "string", "description": "Bounding box filter"},
409
- "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
410
- "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
411
- "z": {"type": "string", "description": "Vertical level(s)"},
427
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
412
428
  "f": {"type": "string", "description": "Response format"},
413
429
  },
414
- },
415
- "locations": {
416
- "type": "object",
417
- "properties": {
418
- "bbox": {
419
- "type": "array",
420
- "description": "Bounding box [minLon, minLat, maxLon, maxLat]",
421
- "items": {"type": "number"},
422
- "minItems": 4,
423
- "maxItems": 6,
424
- },
425
- "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
426
- "f": {"type": "string", "description": "Response format"},
427
- },
428
- },
429
430
  }
430
-
431
-
432
- 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:
433
433
  """Build a POST operation mirroring a GET EDR data query operation."""
434
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
+
435
443
  # Carry over a default output format from the GET operation's `f` parameter
436
444
  # so the POST body documents the same default.
437
445
  default_f = None
@@ -440,8 +448,6 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
440
448
  default_f = p.get("schema", {}).get("default")
441
449
  break
442
450
  if default_f and "properties" in body_schema and "f" in body_schema["properties"]:
443
- import copy
444
- body_schema = copy.deepcopy(body_schema)
445
451
  body_schema["properties"]["f"]["default"] = default_f
446
452
  return {
447
453
  "summary": get_op["summary"].replace("query", "query (POST)"),
@@ -465,35 +471,35 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
465
471
  _QUERY_PARAMS: dict[str, list[dict]] = {
466
472
  "position": [
467
473
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
468
- _DATETIME, _PARAM_NAME, _Z, _F,
474
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
469
475
  ],
470
476
  "area": [
471
477
  {"$ref": f"{_EDR}/parameters/areaCoords.yaml"},
472
- _DATETIME, _PARAM_NAME, _Z, _F,
478
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
473
479
  ],
474
480
  "radius": [
475
481
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
476
482
  {"$ref": f"{_EDR}/parameters/within.yaml"},
477
483
  {"name": "within-units", "in": "query", "required": True, "description": "Units for the within parameter e.g. km", "schema": {"type": "string"}},
478
- _DATETIME, _PARAM_NAME, _Z, _F,
484
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
479
485
  ],
480
486
  "cube": [
481
487
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
482
- _DATETIME, _PARAM_NAME, _Z, _F,
488
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
483
489
  ],
484
490
  "trajectory": [
485
491
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
486
- _DATETIME, _PARAM_NAME, _Z, _F,
492
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
487
493
  ],
488
494
  "corridor": [
489
495
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
490
496
  {"name": "corridor-width", "in": "query", "required": True, "description": "Width of the corridor", "schema": {"type": "number"}},
491
497
  {"name": "corridor-height", "in": "query", "description": "Height of the corridor", "schema": {"type": "number"}},
492
- _DATETIME, _PARAM_NAME, _Z, _F,
498
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
493
499
  ],
494
500
  "items": [
495
501
  {"name": "bbox", "in": "query", "description": "Bounding box filter", "schema": {"type": "string"}},
496
- _DATETIME, _PARAM_NAME, _Z, _F,
502
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
497
503
  ],
498
504
  "locations": [
499
505
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
@@ -502,257 +508,14 @@ _QUERY_PARAMS: dict[str, list[dict]] = {
502
508
  "instances": [_F],
503
509
  }
504
510
 
505
-
506
511
  def _operation_id(*parts: str) -> str:
507
- """Build a valid operationId (letters, digits, underscores only).
508
-
509
- Each part is sanitized by splitting on non-alphanumeric characters,
510
- capitalizing the first letter of each word while preserving the rest,
511
- then joining everything together.
512
- """
513
512
  import re as _re
514
-
515
513
  def sanitize(s: str) -> str:
516
514
  words = _re.split(r"[^a-zA-Z0-9]+", s)
517
515
  return "".join((w[0].upper() + w[1:]) for w in words if w)
518
-
519
516
  return "".join(sanitize(p) for p in parts)
520
517
 
521
518
 
522
- def _collection_response_schema(coll: Collection,
523
- profile: "ServiceProfile | None") -> dict:
524
- """Build a 200 response schema for a single collection endpoint.
525
-
526
- Includes:
527
- - CRS constraints from extent_requirements (enum or pattern)
528
- - TRS/VRS constraints from extent_requirements
529
- - Full temporal extent schema (interval, values, trs)
530
- - Collection-level crs array
531
- - Full parameter_names objects with all fields
532
- - parameter_name_pattern as propertyNames constraint
533
- """
534
- version = profile.openapi_version if profile else "3.0.0"
535
- crs_schema: dict = {"type": "string"} # extent.spatial.crs
536
- trs_schema: dict = {"type": "string"} # extent.temporal.trs
537
- vrs_schema: dict = {"type": "string"} # extent.vertical.vrs
538
- supported_crs_schema: dict = {"type": "string"} # crs[] and crs_details[].crs
539
-
540
- if profile and profile.extent_requirements:
541
- er = profile.extent_requirements
542
-
543
- def _apply(schema: dict, constraint: "object | None") -> None:
544
- if constraint is None:
545
- return
546
- if getattr(constraint, "allowed", None):
547
- schema["enum"] = constraint.allowed
548
- elif getattr(constraint, "pattern", None):
549
- schema["pattern"] = constraint.pattern
550
-
551
- _apply(crs_schema, er.extent_crs)
552
- _apply(trs_schema, er.extent_trs)
553
- _apply(vrs_schema, er.extent_vrs)
554
- _apply(supported_crs_schema, er.supported_crs)
555
-
556
- # Build parameter_names schema. Precedence: per-collection parameter_schema
557
- # override → profile-level parameter_schema → default EDR Parameter schema.
558
- coll_param_schema = getattr(coll, "parameter_schema", None)
559
- if coll_param_schema:
560
- import copy
561
- param_item_schema = copy.deepcopy(coll_param_schema)
562
- elif profile and profile.parameter_schema:
563
- # Deep-copy so we don't mutate the profile model
564
- import copy
565
- param_item_schema = copy.deepcopy(profile.parameter_schema)
566
- else:
567
- param_item_schema = _parameter_schema(profile.openapi_version)
568
-
569
- param_names_schema: dict = {
570
- "type": "object",
571
- "additionalProperties": param_item_schema,
572
- }
573
- if coll.parameter_names:
574
- param_names_schema["properties"] = {
575
- name: param_item_schema for name in coll.parameter_names.root
576
- }
577
- if version == "3.1.0" and profile and profile.parameter_name_pattern:
578
- param_names_schema["propertyNames"] = {
579
- "type": "string",
580
- "pattern": profile.parameter_name_pattern,
581
- }
582
-
583
- # Build output_formats enum from collection's declared formats
584
- output_formats_schema: dict = {"type": "array", "items": {"type": "string"}}
585
- if coll.output_formats:
586
- output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
587
-
588
- # Build crs array schema — use collection-level crs list if present,
589
- # otherwise apply supported_crs constraint from extent_requirements.
590
- # supported_crs is distinct from extent_crs: it constrains what the
591
- # service supports for queries, not how the extent itself is expressed.
592
- if coll.crs:
593
- crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
594
- else:
595
- crs_array_schema = {"type": "array", "items": supported_crs_schema}
596
-
597
- schema: dict = {
598
- "type": "object",
599
- "required": ["id", "links", "extent"],
600
- "properties": {
601
- "id": {"type": "string"},
602
- "title": {"type": "string"},
603
- "description": {"type": "string"},
604
- "keywords": {"type": "array", "items": {"type": "string"}},
605
- "links": _LINKS_ARRAY,
606
- "crs": crs_array_schema,
607
- "output_formats": output_formats_schema,
608
- "parameter_names": param_names_schema,
609
- "extent": {
610
- "type": "object",
611
- "required": ["spatial"],
612
- "properties": {
613
- "spatial": {
614
- "type": "object",
615
- "required": ["bbox", "crs"],
616
- "properties": {
617
- "bbox": {
618
- "type": "array",
619
- "items": {
620
- "type": "array",
621
- "items": {"type": "number"},
622
- "minItems": 4,
623
- "maxItems": 6,
624
- },
625
- },
626
- "crs": crs_schema,
627
- },
628
- },
629
- "temporal": {
630
- "type": "object",
631
- "properties": {
632
- "interval": {
633
- "type": "array",
634
- "items": {
635
- "type": "array",
636
- "items": {"type": "string", "format": "date-time", "nullable": True} if version == "3.0.0" else {
637
- "oneOf": [
638
- {"type": "string", "format": "date-time"},
639
- {"type": "null"},
640
- ]
641
- },
642
- "minItems": 2,
643
- "maxItems": 2,
644
- },
645
- },
646
- "values": {
647
- "type": "array",
648
- "items": {"type": "string"},
649
- },
650
- "trs": trs_schema,
651
- },
652
- },
653
- "vertical": {
654
- "type": "object",
655
- "properties": {
656
- "interval": {
657
- "type": "array",
658
- "items": {
659
- "type": "array",
660
- "items": {"type": "string"},
661
- },
662
- },
663
- "values": {
664
- "type": "array",
665
- "items": {"type": "string"},
666
- },
667
- "vrs": vrs_schema,
668
- "positive": {
669
- "type": "string",
670
- "enum": ["up", "down"],
671
- "description": (
672
- "Direction in which vertical extent values increase "
673
- "(CF Conventions 'positive' attribute). Disambiguates "
674
- "interval ordering for VRS such as pressure levels."
675
- ),
676
- },
677
- },
678
- },
679
- "custom": {
680
- "type": "array",
681
- "items": {
682
- "type": "object",
683
- "required": ["id", "interval", "reference"],
684
- "properties": {
685
- "id": {"type": "string"},
686
- "interval": {
687
- "type": "array",
688
- "items": {"type": "string"},
689
- },
690
- "values": {
691
- "type": "array",
692
- "items": {"type": "string"},
693
- },
694
- "reference": {"type": "string"},
695
- },
696
- },
697
- },
698
- },
699
- },
700
- "data_queries": {
701
- "type": "object",
702
- "description": "Available EDR data query types for this collection",
703
- "additionalProperties": {
704
- "type": "object",
705
- "properties": {
706
- "link": {
707
- "type": "object",
708
- "properties": {
709
- "href": {"type": "string"},
710
- "rel": {"type": "string"},
711
- "variables": {
712
- "type": "object",
713
- "properties": {
714
- "query_type": {"type": "string"},
715
- "output_formats": {
716
- "type": "array",
717
- "items": {"type": "string"},
718
- },
719
- "default_output_format": {
720
- "type": "string",
721
- "description": "Default output format for this data query when f is omitted",
722
- },
723
- "crs": {
724
- "type": "array",
725
- "description": "CRS values supported by this data query (shorthand for crs_details)",
726
- "items": {"type": "string"},
727
- },
728
- "crs_details": {
729
- "type": "array",
730
- "description": "CRS values supported by this data query",
731
- "items": {
732
- "type": "object",
733
- "required": ["crs"],
734
- "properties": {
735
- "crs": supported_crs_schema,
736
- "wkt": {"type": "string"},
737
- },
738
- },
739
- },
740
- },
741
- },
742
- },
743
- },
744
- },
745
- },
746
- },
747
- },
748
- }
749
-
750
- return {
751
- "description": "Collection metadata",
752
- "content": {"application/json": {"schema": schema}},
753
- }
754
-
755
-
756
519
  def _parameter_schema(version: str) -> dict:
757
520
  """OpenAPI schema for a single EDR Parameter object."""
758
521
  return {
@@ -807,6 +570,163 @@ def _parameter_schema(version: str) -> dict:
807
570
  }
808
571
 
809
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}}}
810
730
  def _f_enum(profile: "ServiceProfile | None") -> list[str]:
811
731
  """Allowed values for the `f` (output format) parameter: media types."""
812
732
  enum = ["json", "html"]
@@ -865,6 +785,40 @@ def _f_param(profile: "ServiceProfile | None", variables: object | None) -> dict
865
785
  }
866
786
 
867
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
+
868
822
  def _params_with_default_f(params: list[dict], profile: "ServiceProfile | None",
869
823
  variables: object | None) -> list[dict]:
870
824
  """Replace the shared `_F` reference in a params list with a query-specific default."""
@@ -979,7 +933,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
979
933
  return qv.link.variables if qv is not None and qv.link else None
980
934
 
981
935
  for qt in active:
982
- 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))
983
937
 
984
938
  if qt == "instances":
985
939
  paths[f"{base}/instances"] = {"get": {
@@ -1011,7 +965,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1011
965
  }}
1012
966
  # instance-level query sub-paths — GET + optional POST
1013
967
  for sub_qt in (active - {"instances"}):
1014
- 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))
1015
969
  if sub_qt == "items":
1016
970
  sub_params = _with_paging_limit(sub_params, profile)
1017
971
  get_op = {
@@ -1024,7 +978,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1024
978
  }
1025
979
  path_ops = {"get": get_op}
1026
980
  if include_post:
1027
- 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)
1028
982
  paths[f"{base}/instances/{{instanceId}}/{sub_qt}"] = path_ops
1029
983
 
1030
984
  elif qt == "items":
@@ -1039,7 +993,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1039
993
  }
1040
994
  path_ops = {"get": get_op}
1041
995
  if include_post:
1042
- path_ops["post"] = _post_operation(get_op, "items", coll.id, cov_resp)
996
+ path_ops["post"] = _post_operation(get_op, "items", coll, cov_resp)
1043
997
  paths[f"{base}/items"] = path_ops
1044
998
 
1045
999
  elif qt == "locations":
@@ -1058,7 +1012,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1058
1012
  }
1059
1013
  path_ops = {"get": get_locations_op}
1060
1014
  if include_post:
1061
- 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)
1062
1016
  paths[f"{base}/locations"] = path_ops
1063
1017
 
1064
1018
  get_location_op = {
@@ -1074,7 +1028,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1074
1028
  }
1075
1029
  loc_path_ops = {"get": get_location_op}
1076
1030
  if include_post:
1077
- 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)
1078
1032
  paths[f"{base}/locations/{{locId}}"] = loc_path_ops
1079
1033
 
1080
1034
  else:
@@ -1088,7 +1042,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1088
1042
  }
1089
1043
  path_ops = {"get": get_op}
1090
1044
  if include_post:
1091
- path_ops["post"] = _post_operation(get_op, qt, coll.id, cov_resp)
1045
+ path_ops["post"] = _post_operation(get_op, qt, coll, cov_resp)
1092
1046
  paths[f"{base}/{qt}"] = path_ops
1093
1047
 
1094
1048
  return paths
@@ -1096,15 +1050,10 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1096
1050
 
1097
1051
  def _core_paths(profile: ServiceProfile) -> dict:
1098
1052
  version = profile.openapi_version
1099
- landing_response = _landing_page_schema(
1100
- profile.req_uri, version,
1101
- title=profile.title,
1102
- description=profile.description or "",
1103
- keywords=profile.keywords or None,
1104
- )
1053
+ landing_response = _landing_page_schema(profile)
1105
1054
 
1106
1055
  # Conformance response with required conformance classes
1107
- conformance_response = _R200_CONFORMANCE.copy()
1056
+ conformance_response = copy.deepcopy(_R200_CONFORMANCE)
1108
1057
  if profile.required_conformance_classes:
1109
1058
  if version == "3.1.0":
1110
1059
  conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
@@ -1141,7 +1090,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
1141
1090
  "parameters": [_F, _LANG],
1142
1091
  "responses": {"200": {"description": "Collections list", "content": {"application/json": {"schema": {"type": "object", "properties": {"links": _LINKS_ARRAY}}}}}, "400": _ERR_400, "500": _ERR_500},
1143
1092
  }},
1144
- "/openapi": {"get": {
1093
+ "/api": {"get": {
1145
1094
  "summary": "OpenAPI definition",
1146
1095
  "description": "Retrieve the OpenAPI definition document for this API.",
1147
1096
  "operationId": "getOpenAPI",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.6.3
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