oapi-profile-builder 3.6.3__tar.gz → 3.7.1__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.1}/PKG-INFO +1 -1
  2. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/pyproject.toml +1 -1
  3. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/generate.py +291 -341
  4. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1/src/oapi_profile_builder.egg-info}/PKG-INFO +1 -1
  5. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/LICENSE +0 -0
  6. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/README.md +0 -0
  7. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/setup.cfg +0 -0
  8. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/__init__.py +0 -0
  9. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/cite.py +0 -0
  10. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/cite_features.py +0 -0
  11. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/cli.py +0 -0
  12. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/compile.py +0 -0
  13. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/cover.py +0 -0
  14. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/models.py +0 -0
  15. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.6.3 → oapi_profile_builder-3.7.1}/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.1
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.1"
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,23 @@ _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)"},
412
- "f": {"type": "string", "description": "Response format"},
413
- },
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"},
427
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
426
428
  "f": {"type": "string", "description": "Response format"},
427
429
  },
428
430
  },
429
431
  }
430
432
 
431
-
432
- def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict) -> dict:
433
+ def _post_operation(get_op: dict, query_type: str, coll: object, responses: dict) -> dict:
433
434
  """Build a POST operation mirroring a GET EDR data query operation."""
434
435
  body_schema = _POST_BODY_SCHEMAS.get(query_type, {"type": "object"})
436
+ import copy
437
+ body_schema = copy.deepcopy(body_schema)
438
+
439
+ # Filter out 'z' if collection has no vertical extent
440
+ if hasattr(coll, 'extent') and coll.extent and not coll.extent.vertical:
441
+ if "properties" in body_schema and "z" in body_schema["properties"]:
442
+ del body_schema["properties"]["z"]
443
+
435
444
  # Carry over a default output format from the GET operation's `f` parameter
436
445
  # so the POST body documents the same default.
437
446
  default_f = None
@@ -440,8 +449,6 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
440
449
  default_f = p.get("schema", {}).get("default")
441
450
  break
442
451
  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
452
  body_schema["properties"]["f"]["default"] = default_f
446
453
  return {
447
454
  "summary": get_op["summary"].replace("query", "query (POST)"),
@@ -465,35 +472,35 @@ def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict
465
472
  _QUERY_PARAMS: dict[str, list[dict]] = {
466
473
  "position": [
467
474
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
468
- _DATETIME, _PARAM_NAME, _Z, _F,
475
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
469
476
  ],
470
477
  "area": [
471
478
  {"$ref": f"{_EDR}/parameters/areaCoords.yaml"},
472
- _DATETIME, _PARAM_NAME, _Z, _F,
479
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
473
480
  ],
474
481
  "radius": [
475
482
  {"$ref": f"{_EDR}/parameters/positionCoords.yaml"},
476
483
  {"$ref": f"{_EDR}/parameters/within.yaml"},
477
484
  {"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,
485
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
479
486
  ],
480
487
  "cube": [
481
488
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
482
- _DATETIME, _PARAM_NAME, _Z, _F,
489
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
483
490
  ],
484
491
  "trajectory": [
485
492
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
486
- _DATETIME, _PARAM_NAME, _Z, _F,
493
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
487
494
  ],
488
495
  "corridor": [
489
496
  {"name": "coords", "in": "query", "required": True, "description": "WKT LineString geometry", "schema": {"type": "string"}},
490
497
  {"name": "corridor-width", "in": "query", "required": True, "description": "Width of the corridor", "schema": {"type": "number"}},
491
498
  {"name": "corridor-height", "in": "query", "description": "Height of the corridor", "schema": {"type": "number"}},
492
- _DATETIME, _PARAM_NAME, _Z, _F,
499
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
493
500
  ],
494
501
  "items": [
495
502
  {"name": "bbox", "in": "query", "description": "Bounding box filter", "schema": {"type": "string"}},
496
- _DATETIME, _PARAM_NAME, _Z, _F,
503
+ _DATETIME, _PARAM_NAME, _CRS, _Z, _F,
497
504
  ],
498
505
  "locations": [
499
506
  {"$ref": f"{_EDR}/parameters/bbox.yaml"},
@@ -502,257 +509,14 @@ _QUERY_PARAMS: dict[str, list[dict]] = {
502
509
  "instances": [_F],
503
510
  }
504
511
 
505
-
506
512
  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
513
  import re as _re
514
-
515
514
  def sanitize(s: str) -> str:
516
515
  words = _re.split(r"[^a-zA-Z0-9]+", s)
517
516
  return "".join((w[0].upper() + w[1:]) for w in words if w)
518
-
519
517
  return "".join(sanitize(p) for p in parts)
520
518
 
521
519
 
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
520
  def _parameter_schema(version: str) -> dict:
757
521
  """OpenAPI schema for a single EDR Parameter object."""
758
522
  return {
@@ -807,6 +571,163 @@ def _parameter_schema(version: str) -> dict:
807
571
  }
808
572
 
809
573
 
574
+ def _parameter_instance_schema(param: object, version: str) -> dict:
575
+ """Build a fixed OpenAPI schema for a specific Parameter instance."""
576
+ schema = {
577
+ "type": "object",
578
+ "required": ["type", "observedProperty"],
579
+ "properties": {
580
+ "type": {"type": "string", **_const("Parameter", version)},
581
+ }
582
+ }
583
+ for field in ("id", "label", "description", "data-type"):
584
+ val = getattr(param, field.replace("-", "_"), None)
585
+ if val:
586
+ schema["properties"][field] = {"type": "string", **_const(val, version)}
587
+
588
+ op = getattr(param, "observedProperty", None)
589
+ if op:
590
+ op_props = {"label": {"type": "string", **_const(op.label, version)}}
591
+ if getattr(op, "id", None):
592
+ op_props["id"] = {"type": "string", **_const(op.id, version)}
593
+ if getattr(op, "description", None):
594
+ op_props["description"] = {"type": "string", **_const(op.description, version)}
595
+ schema["properties"]["observedProperty"] = {"type": "object", "properties": op_props, "required": ["label"]}
596
+
597
+ u = getattr(param, "unit", None)
598
+ if u:
599
+ u_props = {}
600
+ if getattr(u, "label", None):
601
+ u_props["label"] = {"type": "string", **_const(u.label, version)}
602
+ symbol = getattr(u, "symbol", None)
603
+ if symbol:
604
+ if isinstance(symbol, str):
605
+ u_props["symbol"] = {"type": "string", **_const(symbol, version)}
606
+ elif hasattr(symbol, "value") and hasattr(symbol, "type"):
607
+ u_props["symbol"] = {
608
+ "type": "object",
609
+ "required": ["value", "type"],
610
+ "properties": {
611
+ "value": {"type": "string", **_const(symbol.value, version)},
612
+ "type": {"type": "string", **_const(symbol.type, version)}
613
+ }
614
+ }
615
+ schema["properties"]["unit"] = {"type": "object", "properties": u_props}
616
+
617
+ return schema
618
+
619
+
620
+ def _collection_response_schema(coll: Collection,
621
+ profile: "ServiceProfile | None") -> dict:
622
+ """Build a 200 response schema for a single collection endpoint."""
623
+ version = profile.openapi_version if profile else "3.0.0"
624
+ crs_schema: dict = {"type": "string"}
625
+ trs_schema: dict = {"type": "string"}
626
+ vrs_schema: dict = {"type": "string"}
627
+ supported_crs_schema: dict = {"type": "string"}
628
+
629
+ if profile and profile.extent_requirements:
630
+ er = profile.extent_requirements
631
+ def _apply(schema: dict, constraint: "object | None") -> None:
632
+ if constraint is None: return
633
+ if getattr(constraint, "allowed", None): schema["enum"] = constraint.allowed
634
+ elif getattr(constraint, "pattern", None): schema["pattern"] = constraint.pattern
635
+ _apply(crs_schema, er.extent_crs)
636
+ _apply(trs_schema, er.extent_trs)
637
+ _apply(vrs_schema, er.extent_vrs)
638
+ _apply(supported_crs_schema, er.supported_crs)
639
+
640
+ coll_param_schema = getattr(coll, "parameter_schema", None)
641
+ if coll_param_schema:
642
+ import copy
643
+ param_item_schema = copy.deepcopy(coll_param_schema)
644
+ elif profile and profile.parameter_schema:
645
+ import copy
646
+ param_item_schema = copy.deepcopy(profile.parameter_schema)
647
+ else:
648
+ param_item_schema = _parameter_schema(profile.openapi_version)
649
+
650
+ param_names_schema: dict = {"type": "object", "additionalProperties": param_item_schema}
651
+ if coll.parameter_names:
652
+ param_names_schema["properties"] = {
653
+ name: _parameter_instance_schema(param, version)
654
+ for name, param in coll.parameter_names.root.items()
655
+ }
656
+ if version == "3.1.0" and profile and profile.parameter_name_pattern:
657
+ param_names_schema["propertyNames"] = {"type": "string", "pattern": profile.parameter_name_pattern}
658
+
659
+ output_formats_schema: dict = {"type": "array", "items": {"type": "string"}}
660
+ if coll.output_formats:
661
+ output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
662
+
663
+ if coll.crs: crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
664
+ else: crs_array_schema = {"type": "array", "items": supported_crs_schema}
665
+
666
+ extent_props: dict = {}
667
+ if coll.extent and coll.extent.spatial:
668
+ s = coll.extent.spatial
669
+ extent_props["spatial"] = {
670
+ "type": "object", "required": ["bbox", "crs"],
671
+ "properties": {
672
+ "bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **_const(s.bbox, version)},
673
+ "crs": {"type": "string", **_const(s.crs, version)}
674
+ }
675
+ }
676
+ if coll.extent and coll.extent.temporal:
677
+ t = coll.extent.temporal
678
+ temporal_props = {"trs": {"type": "string", **_const(t.trs, version)}}
679
+ if t.interval: temporal_props["interval"] = {"type": "array", **_const(t.interval, version)}
680
+ if t.values: temporal_props["values"] = {"type": "array", **_const(t.values, version)}
681
+ extent_props["temporal"] = {"type": "object", "required": ["trs"], "properties": temporal_props}
682
+ if coll.extent and coll.extent.vertical:
683
+ v = coll.extent.vertical
684
+ vertical_props = {"vrs": {"type": "string", **_const(v.vrs, version)}}
685
+ if v.interval: vertical_props["interval"] = {"type": "array", **_const(v.interval, version)}
686
+ if v.values: vertical_props["values"] = {"type": "array", **_const(v.values, version)}
687
+ if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **_const(v.positive, version)}
688
+ extent_props["vertical"] = {"type": "object", "required": ["vrs"], "properties": vertical_props}
689
+ if coll.extent and coll.extent.custom:
690
+ custom_items = [{"type": "object", "required": ["id", "interval", "reference"],
691
+ "properties": {"id": {"type": "string", **_const(c.id, version)},
692
+ "interval": {"type": "array", **_const(c.interval, version)},
693
+ "reference": {"type": "string", **_const(c.reference, version)}}} for c in coll.extent.custom]
694
+ extent_props["custom"] = {"type": "array", "items": {"oneOf": custom_items}}
695
+
696
+ dq_props = {}
697
+ if coll.data_queries:
698
+ for dq_name, dq_val in coll.data_queries:
699
+ if dq_val is None: continue
700
+ vars_props = {}
701
+ vars_obj = dq_val.link.variables
702
+ if vars_obj:
703
+ for f in ("query_type", "default_output_format"):
704
+ v = getattr(vars_obj, f, None);
705
+ if v: vars_props[f] = {"type": "string", **_const(v, version)}
706
+ if getattr(vars_obj, "output_formats", None):
707
+ vars_props["output_formats"] = {"type": "array", "items": {"type": "string", "enum": list(vars_obj.output_formats)}}
708
+ extra = getattr(vars_obj, "model_extra", {}) or {}
709
+ if "crs" in extra: vars_props["crs"] = {"type": "array", "items": {"type": "string"}, **_const(extra["crs"], version)}
710
+ if "crs_details" in extra: vars_props["crs_details"] = {"type": "array", **_const(extra["crs_details"], version)}
711
+ dq_props[dq_name] = {"type": "object", "properties": {"link": {"type": "object", "properties": {
712
+ "href": {"type": "string", **_const(dq_val.link.href, version)},
713
+ "rel": {"type": "string", **_const(dq_val.link.rel, version)},
714
+ "variables": {"type": "object", "properties": vars_props}
715
+ }}}}
716
+
717
+ schema: dict = {
718
+ "type": "object", "required": ["id", "links", "extent"],
719
+ "properties": {
720
+ "id": {"type": "string", **_const(coll.id, version)},
721
+ "title": {"type": "string", **_const(coll.title, version)} if coll.title else {"type": "string"},
722
+ "description": {"type": "string", **_const(coll.description, version)} if coll.description else {"type": "string"},
723
+ "keywords": {"type": "array", "items": {"type": "string", "enum": coll.keywords}} if coll.keywords else {"type": "array"},
724
+ "links": _LINKS_ARRAY, "crs": crs_array_schema, "output_formats": output_formats_schema,
725
+ "parameter_names": param_names_schema,
726
+ "extent": {"type": "object", "required": ["spatial"], "properties": extent_props},
727
+ "data_queries": {"type": "object", "properties": dq_props}
728
+ }
729
+ }
730
+ return {"description": "Collection metadata", "content": {"application/json": {"schema": schema}}}
810
731
  def _f_enum(profile: "ServiceProfile | None") -> list[str]:
811
732
  """Allowed values for the `f` (output format) parameter: media types."""
812
733
  enum = ["json", "html"]
@@ -865,6 +786,40 @@ def _f_param(profile: "ServiceProfile | None", variables: object | None) -> dict
865
786
  }
866
787
 
867
788
 
789
+
790
+ def _params_for_query(params: list[dict], profile: "ServiceProfile | None",
791
+ coll: Collection, variables: object | None) -> list[dict]:
792
+ """Refine query parameters based on collection capabilities (z filtering, crs enum)."""
793
+ import copy
794
+ out = []
795
+ has_vertical = coll.extent and coll.extent.vertical
796
+ supported_crs = list(coll.crs) if coll.crs else []
797
+
798
+ # Check extent_requirements for supported_crs if not explicit on collection
799
+ if not supported_crs and profile and profile.extent_requirements and profile.extent_requirements.supported_crs:
800
+ supported_crs = profile.extent_requirements.supported_crs.allowed or []
801
+
802
+ for p in params:
803
+ if p is _Z and not has_vertical:
804
+ continue
805
+
806
+ # Make a copy if we need to modify (e.g. for f default or crs enum)
807
+ p_eff = p
808
+
809
+ # Handle f parameter default
810
+ if p is _F:
811
+ p_eff = _f_param(profile, variables)
812
+
813
+ # Handle crs parameter enum
814
+ elif p is _CRS and supported_crs:
815
+ p_eff = copy.deepcopy(p)
816
+ if "schema" not in p_eff: p_eff["schema"] = {}
817
+ p_eff["schema"]["type"] = "string"
818
+ p_eff["schema"]["enum"] = supported_crs
819
+
820
+ out.append(p_eff)
821
+ return out
822
+
868
823
  def _params_with_default_f(params: list[dict], profile: "ServiceProfile | None",
869
824
  variables: object | None) -> list[dict]:
870
825
  """Replace the shared `_F` reference in a params list with a query-specific default."""
@@ -979,7 +934,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
979
934
  return qv.link.variables if qv is not None and qv.link else None
980
935
 
981
936
  for qt in active:
982
- params = _params_with_default_f(_QUERY_PARAMS.get(qt, []), profile, _vars(qt))
937
+ params = _params_for_query(_QUERY_PARAMS.get(qt, []), profile, coll, _vars(qt))
983
938
 
984
939
  if qt == "instances":
985
940
  paths[f"{base}/instances"] = {"get": {
@@ -1011,7 +966,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1011
966
  }}
1012
967
  # instance-level query sub-paths — GET + optional POST
1013
968
  for sub_qt in (active - {"instances"}):
1014
- sub_params = _params_with_default_f(_QUERY_PARAMS.get(sub_qt, []), profile, _vars(sub_qt))
969
+ sub_params = _params_for_query(_QUERY_PARAMS.get(sub_qt, []), profile, coll, _vars(sub_qt))
1015
970
  if sub_qt == "items":
1016
971
  sub_params = _with_paging_limit(sub_params, profile)
1017
972
  get_op = {
@@ -1024,7 +979,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1024
979
  }
1025
980
  path_ops = {"get": get_op}
1026
981
  if include_post:
1027
- path_ops["post"] = _post_operation(get_op, sub_qt, coll.id, cov_resp)
982
+ path_ops["post"] = _post_operation(get_op, sub_qt, coll, cov_resp)
1028
983
  paths[f"{base}/instances/{{instanceId}}/{sub_qt}"] = path_ops
1029
984
 
1030
985
  elif qt == "items":
@@ -1039,7 +994,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1039
994
  }
1040
995
  path_ops = {"get": get_op}
1041
996
  if include_post:
1042
- path_ops["post"] = _post_operation(get_op, "items", coll.id, cov_resp)
997
+ path_ops["post"] = _post_operation(get_op, "items", coll, cov_resp)
1043
998
  paths[f"{base}/items"] = path_ops
1044
999
 
1045
1000
  elif qt == "locations":
@@ -1058,7 +1013,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1058
1013
  }
1059
1014
  path_ops = {"get": get_locations_op}
1060
1015
  if include_post:
1061
- path_ops["post"] = _post_operation(get_locations_op, "locations", coll.id, locations_responses)
1016
+ path_ops["post"] = _post_operation(get_locations_op, "locations", coll, locations_responses)
1062
1017
  paths[f"{base}/locations"] = path_ops
1063
1018
 
1064
1019
  get_location_op = {
@@ -1074,7 +1029,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1074
1029
  }
1075
1030
  loc_path_ops = {"get": get_location_op}
1076
1031
  if include_post:
1077
- loc_path_ops["post"] = _post_operation(get_location_op, "locations", coll.id, cov_resp)
1032
+ loc_path_ops["post"] = _post_operation(get_location_op, "locations", coll, cov_resp)
1078
1033
  paths[f"{base}/locations/{{locId}}"] = loc_path_ops
1079
1034
 
1080
1035
  else:
@@ -1088,7 +1043,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1088
1043
  }
1089
1044
  path_ops = {"get": get_op}
1090
1045
  if include_post:
1091
- path_ops["post"] = _post_operation(get_op, qt, coll.id, cov_resp)
1046
+ path_ops["post"] = _post_operation(get_op, qt, coll, cov_resp)
1092
1047
  paths[f"{base}/{qt}"] = path_ops
1093
1048
 
1094
1049
  return paths
@@ -1096,15 +1051,10 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1096
1051
 
1097
1052
  def _core_paths(profile: ServiceProfile) -> dict:
1098
1053
  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
- )
1054
+ landing_response = _landing_page_schema(profile)
1105
1055
 
1106
1056
  # Conformance response with required conformance classes
1107
- conformance_response = _R200_CONFORMANCE.copy()
1057
+ conformance_response = copy.deepcopy(_R200_CONFORMANCE)
1108
1058
  if profile.required_conformance_classes:
1109
1059
  if version == "3.1.0":
1110
1060
  conformance_response["content"]["application/json"]["schema"]["properties"]["conformsTo"]["contains"] = {
@@ -1141,7 +1091,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
1141
1091
  "parameters": [_F, _LANG],
1142
1092
  "responses": {"200": {"description": "Collections list", "content": {"application/json": {"schema": {"type": "object", "properties": {"links": _LINKS_ARRAY}}}}}, "400": _ERR_400, "500": _ERR_500},
1143
1093
  }},
1144
- "/openapi": {"get": {
1094
+ "/api": {"get": {
1145
1095
  "summary": "OpenAPI definition",
1146
1096
  "description": "Retrieve the OpenAPI definition document for this API.",
1147
1097
  "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.1
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