oapi-profile-builder 3.7.5__tar.gz → 3.9.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.7.5/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.9.0}/PKG-INFO +20 -1
  2. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/README.md +19 -0
  3. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cli.py +33 -1
  5. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/generate.py +255 -58
  6. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/models.py +139 -5
  7. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0/src/oapi_profile_builder.egg-info}/PKG-INFO +20 -1
  8. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/LICENSE +0 -0
  9. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/setup.cfg +0 -0
  10. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/__init__.py +0 -0
  11. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite.py +0 -0
  12. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite_features.py +0 -0
  13. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/compile.py +0 -0
  14. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cover.py +0 -0
  15. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/server_validation.py +0 -0
  16. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  17. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  18. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  19. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  20. {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.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.7.5
3
+ Version: 3.9.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
@@ -520,6 +520,25 @@ extent_requirements:
520
520
 
521
521
  At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
522
522
 
523
+ #### CRS as WKT2 strings — quote with single quotes
524
+
525
+ CRS/TRS values can be URIs (as above) **or** full WKT2 strings. A WKT2 string embeds double quotes, square brackets and commas — for example `GEOGCRS["WGS 84",...,ID["EPSG",4326]]`. YAML cannot parse this as a bare scalar or a **double-quoted** scalar (the inner `"` end the string early), so it must be wrapped in **single quotes**, or written as a block scalar:
526
+
527
+ ```yaml
528
+ # Single-quoted (recommended) — the whole WKT2 value in one line:
529
+ crs:
530
+ - 'GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]'
531
+
532
+ # or a block scalar (`|-` keeps it on multiple lines with no trailing newline):
533
+ extent_requirements:
534
+ extent_crs:
535
+ allowed:
536
+ - |-
537
+ GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]
538
+ ```
539
+
540
+ Double-quoting (`"GEOGCRS["WGS 84"...]"`) will fail to parse. If a config fails with a YAML parse error near a CRS line, this is almost always the cause — the CLI prints a hint pointing here. Publish the `wkt` conformance class (`.../conf/wkt`) in `required_conformance_classes` when your CRS values are WKT2.
541
+
523
542
  #### Vertical direction (`positive`)
524
543
 
525
544
  OGC API - EDR's vertical extent has no field for which way values increase. This is ambiguous for VRS like pressure levels, which different providers order top-to-bottom or bottom-to-top inconsistently (pressure increases as altitude *decreases*). Set `extent.vertical.positive` to `up` or `down` on a collection (following the CF Conventions `positive` attribute) to make the ordering explicit:
@@ -473,6 +473,25 @@ extent_requirements:
473
473
 
474
474
  At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
475
475
 
476
+ #### CRS as WKT2 strings — quote with single quotes
477
+
478
+ CRS/TRS values can be URIs (as above) **or** full WKT2 strings. A WKT2 string embeds double quotes, square brackets and commas — for example `GEOGCRS["WGS 84",...,ID["EPSG",4326]]`. YAML cannot parse this as a bare scalar or a **double-quoted** scalar (the inner `"` end the string early), so it must be wrapped in **single quotes**, or written as a block scalar:
479
+
480
+ ```yaml
481
+ # Single-quoted (recommended) — the whole WKT2 value in one line:
482
+ crs:
483
+ - 'GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]'
484
+
485
+ # or a block scalar (`|-` keeps it on multiple lines with no trailing newline):
486
+ extent_requirements:
487
+ extent_crs:
488
+ allowed:
489
+ - |-
490
+ GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]
491
+ ```
492
+
493
+ Double-quoting (`"GEOGCRS["WGS 84"...]"`) will fail to parse. If a config fails with a YAML parse error near a CRS line, this is almost always the cause — the CLI prints a hint pointing here. Publish the `wkt` conformance class (`.../conf/wkt`) in `required_conformance_classes` when your CRS values are WKT2.
494
+
476
495
  #### Vertical direction (`positive`)
477
496
 
478
497
  OGC API - EDR's vertical extent has no field for which way values increase. This is ambiguous for VRS like pressure levels, which different providers order top-to-bottom or bottom-to-top inconsistently (pressure increases as altitude *decreases*). Set `extent.vertical.positive` to `up` or `down` on a collection (following the CF Conventions `positive` attribute) to make the ordering explicit:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.7.5"
7
+ version = "3.9.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -71,10 +71,42 @@ def _parse_datetimes(obj, _in_examples: bool = False):
71
71
  def load_config(path: Path) -> dict:
72
72
  text = path.read_text(encoding="utf-8")
73
73
  if path.suffix in {".yaml", ".yml"}:
74
- return _parse_datetimes(yaml.safe_load(text))
74
+ try:
75
+ data = yaml.safe_load(text)
76
+ except yaml.YAMLError as exc:
77
+ raise SystemExit(_yaml_error_hint(exc)) from exc
78
+ return _parse_datetimes(data)
75
79
  return _parse_datetimes(json.loads(text))
76
80
 
77
81
 
82
+ def _yaml_error_hint(exc: "yaml.YAMLError") -> str:
83
+ """Turn a raw PyYAML parse error into an actionable message.
84
+
85
+ The most common failure is an unquoted or double-quoted WKT2 CRS string.
86
+ WKT2 embeds double quotes, square brackets and commas
87
+ (e.g. GEOGCRS["WGS 84",...,ID["EPSG",4326]]), which YAML cannot parse as a
88
+ bare or double-quoted scalar. The value must be wrapped in SINGLE quotes
89
+ (or written as a block scalar with `|-`).
90
+ """
91
+ base = f"Failed to parse YAML config:\n{exc}"
92
+ text = str(exc)
93
+ looks_like_wkt = any(tok in text for tok in ("GEOGCRS", "PROJCRS", "BOUNDCRS", "COMPOUNDCRS", '"'))
94
+ if looks_like_wkt or "expected" in text:
95
+ base += (
96
+ "\n\nHint: if a CRS/TRS value is a WKT2 string (e.g. "
97
+ 'GEOGCRS[\"WGS 84\",...,ID[\"EPSG\",4326]]), it contains double quotes, '
98
+ "square brackets and commas that YAML cannot parse unquoted or "
99
+ "double-quoted. Wrap the whole value in SINGLE quotes:\n"
100
+ " crs:\n"
101
+ " - 'GEOGCRS[\"WGS 84\",...,ID[\"EPSG\",4326]]'\n"
102
+ "or use a block scalar:\n"
103
+ " crs:\n"
104
+ " - |-\n"
105
+ ' GEOGCRS["WGS 84",...,ID["EPSG",4326]]'
106
+ )
107
+ return base
108
+
109
+
78
110
  def main() -> None:
79
111
  import argparse
80
112
 
@@ -44,7 +44,21 @@ from oapi_profile_builder.models import ServiceProfile
44
44
  def _const(val, version: str) -> dict:
45
45
  if version == "3.1.0":
46
46
  return {"const": val}
47
- return {"enum": [val]}
47
+ return {"enum": [val], "default": val}
48
+
49
+ def _fixed(val, version: str) -> dict:
50
+ """Render a reference/example value WITHOUT constraining the schema.
51
+
52
+ Descriptive metadata (description, keywords, title, provider details,
53
+ extent values, the profile URI, ...) are reference values a conforming
54
+ service echoes, not the *only* allowed value. Emitting them via ``_const``
55
+ turned every such field into a single-value ``enum`` + ``default`` (OpenAPI
56
+ 3.0) or ``const`` (3.1), which the Swagger Editor flags as a warning and
57
+ which wrongly implies the value is the sole permitted one. ``_fixed`` uses
58
+ ``example`` instead: the value is surfaced for documentation/Swagger's
59
+ "Example Value" pane, but the field stays an unconstrained typed schema.
60
+ """
61
+ return {"example": val}
48
62
 
49
63
  def _contains(schema: dict, version: str) -> dict:
50
64
  if version == "3.1.0":
@@ -164,62 +178,82 @@ def _landing_page_schema(profile: "ServiceProfile") -> dict:
164
178
  if getattr(l, "hreflang", None): link_obj["hreflang"] = l.hreflang
165
179
  links_to_show.append(link_obj)
166
180
  else:
181
+ # Default EDR Part 1 landing-page link set: self, service-desc,
182
+ # service-doc, conformance, the collections resource (rel 'data'), and
183
+ # the profile document (rel 'profile' → the spec_uri_base/req/<name> URI).
167
184
  links_to_show = [
168
185
  {"href": "/", "rel": "self", "type": "application/json", "title": "this document"},
169
186
  {"href": "/api", "rel": "service-desc", "type": "application/vnd.oai.openapi+json;version=3.0.0", "title": "the API definition"},
187
+ {"href": "/api.html", "rel": "service-doc", "type": "text/html", "title": "the API documentation"},
170
188
  {"href": "/conformance", "rel": "conformance", "type": "application/json", "title": "OGC API conformance classes implemented by this server"},
171
189
  {"href": "/collections", "rel": "data", "type": "application/json", "title": "Information about the collections"},
172
- {"href": profile_uri, "rel": "profile", "type": "text/html", "title": "The Service Profile for this API"}
190
+ {"href": profile_uri, "rel": "profile", "type": "application/yaml", "title": f"OpenAPI Specification Profile for {profile.name}"},
173
191
  ]
174
192
 
193
+ # Ensure a rel='profile' link is always present, even when the profile
194
+ # supplies its own links (Point 4/9: the profile URI belongs in a link
195
+ # object, not a bespoke x-ogc-profile property).
196
+ if not any(lk.get("rel") == "profile" for lk in links_to_show):
197
+ links_to_show.append(
198
+ {"href": profile_uri, "rel": "profile", "type": "application/yaml",
199
+ "title": f"OpenAPI Specification Profile for {profile.name}"}
200
+ )
201
+
202
+ # Links are surfaced as concrete data via `example` (not enum) so Swagger's
203
+ # "Example Value" pane shows the actual hrefs/rels rather than the schema
204
+ # stub ("href": "string", ...). The schema itself stays unconstrained.
175
205
  schema_props: dict = {
176
206
  "links": {
177
207
  "type": "array",
178
208
  "items": link_items,
179
- "enum": [links_to_show]
209
+ "example": links_to_show,
180
210
  },
181
- "title": {"type": "string", **_const(profile.title, version)},
182
- "version": {"type": "string", **_const(profile.version, version)},
183
- "x-ogc-profile": {"type": "string", **_const(profile_uri, version)}
211
+ "title": {"type": "string", **_fixed(profile.title, version)},
212
+ "version": {"type": "string", **_fixed(profile.version, version)},
184
213
  }
185
-
214
+
186
215
  if profile.description:
187
- schema_props["description"] = {"type": "string", **_const(profile.description, version)}
216
+ schema_props["description"] = {"type": "string", **_fixed(profile.description, version)}
188
217
  if profile.keywords:
189
218
  schema_props["keywords"] = {
190
219
  "type": "array",
191
220
  "items": {"type": "string"},
192
- "enum": [profile.keywords]
221
+ **_fixed(profile.keywords, version)
193
222
  }
194
-
223
+
195
224
  if profile.provider:
196
225
  p = profile.provider
197
- provider_props = {"name": {"type": "string", **_const(p.name, version)}}
198
- if p.url: provider_props["url"] = {"type": "string", **_const(p.url, version)}
226
+ provider_props = {"name": {"type": "string", **_fixed(p.name, version)}}
227
+ if p.url: provider_props["url"] = {"type": "string", **_fixed(p.url, version)}
199
228
  if p.contact:
200
229
  c = p.contact
201
230
  contact_props = {}
202
- if c.email: contact_props["email"] = {"type": "string", **_const(c.email, version)}
231
+ if c.email: contact_props["email"] = {"type": "string", **_fixed(c.email, version)}
232
+ # Response bodies are free-form JSON (not OpenAPI's restricted
233
+ # `info` object), so contact fields keep their input names — no
234
+ # x- prefix (Point 4).
203
235
  for key in ("phone", "hours", "instructions", "address", "postalcode", "city", "country"):
204
236
  val = getattr(c, key, None)
205
- if val: contact_props[f"x-{key}"] = {"type": "string", **_const(val, version)}
237
+ if val: contact_props[key] = {"type": "string", **_fixed(val, version)}
206
238
  if contact_props: provider_props["contact"] = {"type": "object", "properties": contact_props}
207
239
  schema_props["provider"] = {"type": "object", "properties": provider_props}
208
240
 
209
241
  if profile.classification:
210
242
  cl = profile.classification
211
- schema_props["x-classification"] = {
243
+ schema_props["classification"] = {
212
244
  "type": "object",
213
245
  "properties": {
214
- "level": {"type": "string", **_const(cl.level, version)},
215
- "system": {"type": "string", **_const(cl.system, version)} if cl.system else {"type": "string"}
246
+ "classification_level": {"type": "string", **_fixed(cl.classification_level, version)},
247
+ "classification_system": {"type": "string", **_fixed(cl.classification_system, version)} if cl.classification_system else {"type": "string"}
216
248
  }
217
249
  }
218
250
 
251
+ if profile.attribution:
252
+ schema_props["attribution"] = {"type": "string", **_fixed(profile.attribution, version)}
219
253
  if profile.metadata_date:
220
- schema_props["x-metadata-date"] = {"type": "string", **_const(profile.metadata_date, version)}
254
+ schema_props["metadata-date"] = {"type": "string", **_fixed(profile.metadata_date, version)}
221
255
  if profile.resource_service_publish_date:
222
- schema_props["x-resource-publish-date"] = {"type": "string", **_const(profile.resource_service_publish_date, version)}
256
+ schema_props["resource-publish-date"] = {"type": "string", **_fixed(profile.resource_service_publish_date, version)}
223
257
 
224
258
  return {
225
259
  "description": "Landing page",
@@ -454,6 +488,26 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
454
488
  "f": {"type": "string", "description": "Response format"},
455
489
  },
456
490
  },
491
+ # `items` POST mirrors the GET items query parameters (bbox filter,
492
+ # datetime, parameter-name, crs, z, f). Without this entry the POST body
493
+ # fell back to an empty {"type": "object"} (Point 10).
494
+ "items": {
495
+ "type": "object",
496
+ "properties": {
497
+ "bbox": {
498
+ "type": "array",
499
+ "description": "Bounding box filter [minLon, minLat, maxLon, maxLat]",
500
+ "items": {"type": "number"},
501
+ "minItems": 4,
502
+ "maxItems": 6,
503
+ },
504
+ "datetime": {"type": "string", "description": "RFC 3339 datetime or interval"},
505
+ "parameter-name": {"type": "string", "description": "Comma-separated list of parameter names to return"},
506
+ "crs": {"type": "string", "description": "Identifier for the coordinate reference system"},
507
+ "z": {"type": "string", "description": "Vertical level(s)"},
508
+ "f": {"type": "string", "description": "Response format"},
509
+ },
510
+ },
457
511
  }
458
512
 
459
513
  def _post_operation(get_op: dict, query_type: str, coll: object, responses: dict) -> dict:
@@ -685,10 +739,20 @@ def _collection_response_schema(coll: Collection,
685
739
 
686
740
  output_formats_schema: dict = {"type": "array", "items": {"type": "string"}}
687
741
  if coll.output_formats:
688
- output_formats_schema["items"] = {"type": "string", "enum": list(coll.output_formats)}
742
+ output_formats_schema = {
743
+ "type": "array",
744
+ "items": {"type": "string"},
745
+ **_fixed(list(coll.output_formats), version)
746
+ }
689
747
 
690
- if coll.crs: crs_array_schema: dict = {"type": "array", "items": {"type": "string", "enum": list(coll.crs)}}
691
- else: crs_array_schema = {"type": "array", "items": supported_crs_schema}
748
+ if coll.crs:
749
+ crs_array_schema: dict = {
750
+ "type": "array",
751
+ "items": {"type": "string"},
752
+ **_fixed(list(coll.crs), version)
753
+ }
754
+ else:
755
+ crs_array_schema = {"type": "array", "items": supported_crs_schema}
692
756
 
693
757
  extent_props: dict = {}
694
758
  if coll.extent and coll.extent.spatial:
@@ -696,42 +760,42 @@ def _collection_response_schema(coll: Collection,
696
760
  extent_props["spatial"] = {
697
761
  "type": "object", "required": ["bbox", "crs"],
698
762
  "properties": {
699
- "bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **_const(s.bbox, version)},
700
- "crs": {"type": "string", **_const(s.crs, version)}
763
+ "bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **_fixed(s.bbox, version)},
764
+ "crs": {"type": "string", **_fixed(s.crs, version)}
701
765
  }
702
766
  }
703
767
  if coll.extent and coll.extent.temporal:
704
768
  t = coll.extent.temporal
705
- temporal_props = {"trs": {"type": "string", **_const(t.trs, version)}}
769
+ temporal_props = {"trs": {"type": "string", **_fixed(t.trs, version)}}
706
770
  if t.interval:
707
771
  temporal_props["interval"] = {
708
772
  "type": "array",
709
773
  "items": {"type": "array", "items": {"type": "string", "nullable": True}},
710
- **_const(t.interval, version)
774
+ **_fixed(t.interval, version)
711
775
  }
712
776
  if t.values:
713
777
  temporal_props["values"] = {
714
778
  "type": "array",
715
779
  "items": {"type": "string"},
716
- **_const(t.values, version)
780
+ **_fixed(t.values, version)
717
781
  }
718
782
  extent_props["temporal"] = {"type": "object", "required": ["trs"], "properties": temporal_props}
719
783
  if coll.extent and coll.extent.vertical:
720
784
  v = coll.extent.vertical
721
- vertical_props = {"vrs": {"type": "string", **_const(v.vrs, version)}}
785
+ vertical_props = {"vrs": {"type": "string", **_fixed(v.vrs, version)}}
722
786
  if v.interval:
723
787
  vertical_props["interval"] = {
724
788
  "type": "array",
725
789
  "items": {"type": "array", "items": {"type": "number"}},
726
- **_const(v.interval, version)
790
+ **_fixed(v.interval, version)
727
791
  }
728
792
  if v.values:
729
793
  vertical_props["values"] = {
730
794
  "type": "array",
731
795
  "items": {"type": "string"},
732
- **_const(v.values, version)
796
+ **_fixed(v.values, version)
733
797
  }
734
- if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **_const(v.positive, version)}
798
+ if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **_fixed(v.positive, version)}
735
799
  extent_props["vertical"] = {"type": "object", "required": ["vrs"], "properties": vertical_props}
736
800
  if coll.extent and coll.extent.custom:
737
801
  custom_items = []
@@ -740,9 +804,9 @@ def _collection_response_schema(coll: Collection,
740
804
  "type": "object",
741
805
  "required": ["id", "interval", "reference"],
742
806
  "properties": {
743
- "id": {"type": "string", **_const(c.id, version)},
744
- "interval": {"type": "array", "items": {"type": "string"}, **_const(c.interval, version)},
745
- "reference": {"type": "string", **_const(c.reference, version)},
807
+ "id": {"type": "string", **_fixed(c.id, version)},
808
+ "interval": {"type": "array", "items": {"type": "string"}, **_fixed(c.interval, version)},
809
+ "reference": {"type": "string", **_fixed(c.reference, version)},
746
810
  }
747
811
  })
748
812
  extent_props["custom"] = {"type": "array", "items": {"oneOf": custom_items}}
@@ -756,15 +820,15 @@ def _collection_response_schema(coll: Collection,
756
820
  if vars_obj:
757
821
  for f in ("query_type", "default_output_format"):
758
822
  v = getattr(vars_obj, f, None);
759
- if v: vars_props[f] = {"type": "string", **_const(v, version)}
823
+ if v: vars_props[f] = {"type": "string", **_fixed(v, version)}
760
824
  if getattr(vars_obj, "output_formats", None):
761
- vars_props["output_formats"] = {"type": "array", "items": {"type": "string", "enum": list(vars_obj.output_formats)}}
825
+ vars_props["output_formats"] = {"type": "array", "items": {"type": "string"}, **_fixed(list(vars_obj.output_formats), version)}
762
826
  extra = getattr(vars_obj, "model_extra", {}) or {}
763
- if "crs" in extra: vars_props["crs"] = {"type": "array", "items": {"type": "string"}, **_const(extra["crs"], version)}
764
- if "crs_details" in extra: vars_props["crs_details"] = {"type": "array", **_const(extra["crs_details"], version)}
827
+ if "crs" in extra: vars_props["crs"] = {"type": "array", "items": {"type": "string"}, **_fixed(extra["crs"], version)}
828
+ if "crs_details" in extra: vars_props["crs_details"] = {"type": "array", **_fixed(extra["crs_details"], version)}
765
829
  dq_props[dq_name] = {"type": "object", "properties": {"link": {"type": "object", "properties": {
766
- "href": {"type": "string", **_const(dq_val.link.href, version)},
767
- "rel": {"type": "string", **_const(dq_val.link.rel, version)},
830
+ "href": {"type": "string", **_fixed(dq_val.link.href, version)},
831
+ "rel": {"type": "string", **_fixed(dq_val.link.rel, version)},
768
832
  "variables": {"type": "object", "properties": vars_props}
769
833
  }}}}
770
834
 
@@ -777,18 +841,61 @@ def _collection_response_schema(coll: Collection,
777
841
  if getattr(l, "hreflang", None): link_obj["hreflang"] = l.hreflang
778
842
  links_to_show.append(link_obj)
779
843
 
844
+ coll_props: dict = {
845
+ "id": {"type": "string", **_fixed(coll.id, version)},
846
+ "title": {"type": "string", **_fixed(coll.title, version)} if coll.title else {"type": "string"},
847
+ "description": {"type": "string", **_fixed(coll.description, version)} if coll.description else {"type": "string"},
848
+ "keywords": {
849
+ "type": "array",
850
+ "items": {"type": "string"},
851
+ **(_fixed(coll.keywords, version) if coll.keywords else {})
852
+ },
853
+ "links": {
854
+ "type": "array",
855
+ "items": _LINK_SCHEMA,
856
+ **(_fixed(links_to_show, version) if links_to_show else {})
857
+ },
858
+ "crs": crs_array_schema,
859
+ "output_formats": output_formats_schema,
860
+ "parameter_names": {**param_names_schema, "additionalProperties": False},
861
+ "extent": {"type": "object", "required": ["spatial"], "properties": extent_props},
862
+ "data_queries": {"type": "object", "properties": dq_props, "additionalProperties": False}
863
+ }
864
+
865
+ # Per-collection metadata (provider, attribution, classification, dates,
866
+ # locale). Emitted with their plain input names in the response body
867
+ # (Point 4/13). Collection-level values override the profile-level ones.
868
+ prov = coll.provider or (profile.provider if profile else None)
869
+ if prov:
870
+ provider_props = {"name": {"type": "string", **_fixed(prov.name, version)}}
871
+ if prov.url: provider_props["url"] = {"type": "string", **_fixed(prov.url, version)}
872
+ if prov.contact:
873
+ c = prov.contact
874
+ contact_props = {}
875
+ if c.email: contact_props["email"] = {"type": "string", **_fixed(c.email, version)}
876
+ for key in ("phone", "hours", "instructions", "address", "postalcode", "city", "country"):
877
+ val = getattr(c, key, None)
878
+ if val: contact_props[key] = {"type": "string", **_fixed(val, version)}
879
+ if contact_props: provider_props["contact"] = {"type": "object", "properties": contact_props}
880
+ coll_props["provider"] = {"type": "object", "properties": provider_props}
881
+
882
+ attribution = coll.attribution or (profile.attribution if profile else None)
883
+ if attribution:
884
+ coll_props["attribution"] = {"type": "string", **_fixed(attribution, version)}
885
+
886
+ cl = coll.classification or (profile.classification if profile else None)
887
+ if cl:
888
+ coll_props["classification"] = {
889
+ "type": "object",
890
+ "properties": {
891
+ "classification_level": {"type": "string", **_fixed(cl.classification_level, version)},
892
+ "classification_system": {"type": "string", **_fixed(cl.classification_system, version)} if cl.classification_system else {"type": "string"}
893
+ }
894
+ }
895
+
780
896
  schema: dict = {
781
897
  "type": "object", "required": ["id", "links", "extent"],
782
- "properties": {
783
- "id": {"type": "string", **_const(coll.id, version)},
784
- "title": {"type": "string", **_const(coll.title, version)} if coll.title else {"type": "string"},
785
- "description": {"type": "string", **_const(coll.description, version)} if coll.description else {"type": "string"},
786
- "keywords": {"type": "array", "items": {"type": "string"}, "enum": [coll.keywords] if coll.keywords else [[]]},
787
- "links": {"type": "array", "items": _LINK_SCHEMA, "enum": [links_to_show]}, "crs": crs_array_schema, "output_formats": output_formats_schema,
788
- "parameter_names": {**param_names_schema, "additionalProperties": False},
789
- "extent": {"type": "object", "required": ["spatial"], "properties": extent_props},
790
- "data_queries": {"type": "object", "properties": dq_props, "additionalProperties": False}
791
- }
898
+ "properties": coll_props,
792
899
  }
793
900
  return {"description": "Collection metadata", "content": {"application/json": {"schema": schema}}}
794
901
  def _f_enum(profile: "ServiceProfile | None") -> list[str]:
@@ -1112,6 +1219,95 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
1112
1219
  return paths
1113
1220
 
1114
1221
 
1222
+ def _collections_response_schema(profile: "ServiceProfile") -> dict:
1223
+ """Build the /collections 200 response per OGC API - EDR Part 1 (§E.4).
1224
+
1225
+ The response is an object with:
1226
+ - ``links``: a rel 'self' (application/json) and rel 'alternate'
1227
+ (text/html) link, surfaced as example data (Point 5/7);
1228
+ - ``collections``: an array whose items reuse each collection's own
1229
+ response schema (id, title, description, extent, parameter_names,
1230
+ data_queries, provider, ...), so the enumerated collection definitions
1231
+ (e.g. the ``earth_observations_lightning`` collection with its
1232
+ ``frequency_of_lightning_flashes_per_unit_area`` parameter) appear here
1233
+ rather than only a bare links stub (Point 7).
1234
+ """
1235
+ version = profile.openapi_version
1236
+
1237
+ links_example = [
1238
+ {"href": "/collections", "rel": "self", "type": "application/json", "title": "this document"},
1239
+ {"href": "/collections?f=html", "rel": "alternate", "type": "text/html", "title": "this document as HTML"},
1240
+ ]
1241
+
1242
+ # Each collection's item schema is the `schema` node of its own
1243
+ # collection-metadata response, so /collections and /collections/{id}
1244
+ # stay consistent.
1245
+ collection_item_schemas = [
1246
+ _collection_response_schema(coll, profile)["content"]["application/json"]["schema"]
1247
+ for coll in profile.collections
1248
+ ]
1249
+ if len(collection_item_schemas) == 1:
1250
+ collection_items_schema = collection_item_schemas[0]
1251
+ else:
1252
+ collection_items_schema = {"oneOf": collection_item_schemas}
1253
+
1254
+ schema = {
1255
+ "type": "object",
1256
+ "required": ["links", "collections"],
1257
+ "properties": {
1258
+ "links": {
1259
+ "type": "array",
1260
+ "items": _LINK_SCHEMA,
1261
+ "example": links_example,
1262
+ },
1263
+ "collections": {
1264
+ "type": "array",
1265
+ "items": collection_items_schema,
1266
+ },
1267
+ },
1268
+ }
1269
+ return {"description": "Collections", "content": {"application/json": {"schema": schema}}}
1270
+
1271
+
1272
+ def _api_response_schema(profile: "ServiceProfile") -> dict:
1273
+ """Build the /api 200 response.
1274
+
1275
+ The OpenAPI definition document is served here, and (Point 9) it advertises
1276
+ the rel 'profile' link pointing at spec_uri_base/req/<name>, mirroring the
1277
+ profile link in the landing page. The link is surfaced as example data.
1278
+ """
1279
+ profile_link = {
1280
+ "href": profile.req_uri,
1281
+ "rel": "profile",
1282
+ "type": "application/yaml",
1283
+ "title": f"OpenAPI Specification Profile for {profile.name}",
1284
+ }
1285
+ self_link = {
1286
+ "href": "/api",
1287
+ "rel": "self",
1288
+ "type": "application/vnd.oai.openapi+json;version=3.0.0",
1289
+ "title": "this document",
1290
+ }
1291
+ schema = {
1292
+ "type": "object",
1293
+ "description": "OpenAPI definition document",
1294
+ "properties": {
1295
+ "links": {
1296
+ "type": "array",
1297
+ "items": _LINK_SCHEMA,
1298
+ "example": [self_link, profile_link],
1299
+ },
1300
+ },
1301
+ }
1302
+ return {
1303
+ "description": "OpenAPI document",
1304
+ "content": {
1305
+ "application/vnd.oai.openapi+json;version=3.0.0": {"schema": schema},
1306
+ "text/html": {"schema": {"type": "string"}},
1307
+ },
1308
+ }
1309
+
1310
+
1115
1311
  def _core_paths(profile: ServiceProfile) -> dict:
1116
1312
  version = profile.openapi_version
1117
1313
  landing_response = _landing_page_schema(profile)
@@ -1123,11 +1319,10 @@ def _core_paths(profile: ServiceProfile) -> dict:
1123
1319
  if version == "3.1.0":
1124
1320
  schema["properties"]["conformsTo"]["contains"] = {"enum": profile.required_conformance_classes}
1125
1321
  else:
1126
- # Force listing all expected classes in Swagger UI by using enum at the array level
1127
1322
  schema["properties"]["conformsTo"] = {
1128
1323
  "type": "array",
1129
1324
  "items": {"type": "string"},
1130
- "enum": [profile.required_conformance_classes]
1325
+ **_const(profile.required_conformance_classes, version)
1131
1326
  }
1132
1327
 
1133
1328
  return {
@@ -1153,7 +1348,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
1153
1348
  "operationId": "getCollections",
1154
1349
  "tags": ["server"],
1155
1350
  "parameters": [_F, _LANG],
1156
- "responses": {"200": {"description": "Collections list", "content": {"application/json": {"schema": {"type": "object", "properties": {"links": _LINKS_ARRAY}}}}}, "400": _ERR_400, "500": _ERR_500},
1351
+ "responses": {"200": _collections_response_schema(profile), "400": _ERR_400, "500": _ERR_500},
1157
1352
  }},
1158
1353
  "/api": {"get": {
1159
1354
  "summary": "OpenAPI definition",
@@ -1161,7 +1356,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
1161
1356
  "operationId": "getOpenAPI",
1162
1357
  "tags": ["server"],
1163
1358
  "parameters": [_F],
1164
- "responses": {"200": {"description": "OpenAPI document"}, "default": _ERR_DEFAULT},
1359
+ "responses": {"200": _api_response_schema(profile), "default": _ERR_DEFAULT},
1165
1360
  }},
1166
1361
  }
1167
1362
 
@@ -1333,10 +1528,12 @@ def build_openapi(profile: ServiceProfile) -> dict:
1333
1528
  if profile.classification:
1334
1529
  info["x-classification"] = {
1335
1530
  k: v for k, v in (
1336
- ("level", profile.classification.level),
1337
- ("system", profile.classification.system),
1531
+ ("classification_level", profile.classification.classification_level),
1532
+ ("classification_system", profile.classification.classification_system),
1338
1533
  ) if v
1339
1534
  }
1535
+ if profile.attribution:
1536
+ info["x-attribution"] = profile.attribution
1340
1537
  if profile.metadata_date:
1341
1538
  info["x-metadata-date"] = profile.metadata_date
1342
1539
  if profile.resource_service_publish_date:
@@ -1373,7 +1570,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
1373
1570
  "lang": {
1374
1571
  "name": "lang", "in": "query", "required": False,
1375
1572
  "description": "The optional lang parameter instructs the server return a response in a certain language, if supported.",
1376
- "schema": {"type": "string", "default": "en-US", "enum": ["en-US"]},
1573
+ "schema": {"type": "string", "default": profile.effective_language, "enum": profile.effective_languages},
1377
1574
  },
1378
1575
  },
1379
1576
  "responses": {
@@ -2341,7 +2538,7 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
2341
2538
  classification_banner = ""
2342
2539
  if profile.classification:
2343
2540
  cl = profile.classification
2344
- label = cl.level + (f" ({cl.system})" if cl.system else "")
2541
+ label = cl.classification_level + (f" ({cl.classification_system})" if cl.classification_system else "")
2345
2542
  classification_banner = f"*SECURITY CLASSIFICATION: {label}*\n\n"
2346
2543
 
2347
2544
  # --- Point of contact paragraph (from provider) ---
@@ -47,7 +47,15 @@ from annotated_types import Len
47
47
  from edr_pydantic.link import Link
48
48
  from edr_pydantic.collections import Collection as EDRCollection
49
49
  from edr_pydantic.extent import Custom, Extent as EDRExtent, Spatial, Vertical
50
- from pydantic import AwareDatetime, BaseModel, Field, field_validator, model_validator
50
+ from pydantic import (
51
+ AliasChoices,
52
+ AwareDatetime,
53
+ BaseModel,
54
+ ConfigDict,
55
+ Field,
56
+ field_validator,
57
+ model_validator,
58
+ )
51
59
 
52
60
 
53
61
  # ---------------------------------------------------------------------------
@@ -110,9 +118,27 @@ class ExtentWithNullBounds(EDRExtent):
110
118
 
111
119
 
112
120
  class Classification(BaseModel):
113
- """Security classification metadata (e.g. for DGIWG / military profiles)."""
114
- level: str = Field(description="Classification level, e.g. 'NATO RESTRICTED (NR)'")
115
- system: str | None = Field(default=None, description="Classification system, e.g. 'NATO'")
121
+ """Security classification metadata (e.g. for DGIWG / military profiles).
122
+
123
+ The canonical field names are ``classification_level`` and
124
+ ``classification_system`` (a DGIWG requirement). The legacy names
125
+ ``level`` and ``system`` are accepted as input aliases so existing
126
+ profiles keep validating, but the generated output uses the DGIWG names.
127
+ """
128
+
129
+ model_config = ConfigDict(populate_by_name=True)
130
+
131
+ classification_level: str = Field(
132
+ validation_alias=AliasChoices("classification_level", "level"),
133
+ serialization_alias="classification_level",
134
+ description="Classification level, e.g. 'NATO RESTRICTED (NR)'",
135
+ )
136
+ classification_system: str | None = Field(
137
+ default=None,
138
+ validation_alias=AliasChoices("classification_system", "system"),
139
+ serialization_alias="classification_system",
140
+ description="Classification system, e.g. 'NATO'",
141
+ )
116
142
 
117
143
 
118
144
  class ProviderContact(BaseModel):
@@ -175,6 +201,14 @@ class Collection(EDRCollection):
175
201
  # value for this collection. Round-tripped into profile_config.json.
176
202
  classification: Optional[Classification] = None
177
203
  provider: Optional[Provider] = None
204
+ attribution: Optional[str] = Field(
205
+ default=None,
206
+ description=(
207
+ "Attribution / acknowledgement for this collection (OGC API collection "
208
+ "metadata). Overrides the profile-level attribution for this collection. "
209
+ "Surfaces as a plain 'attribution' property in the collection object."
210
+ ),
211
+ )
178
212
  metadata_date: Optional[str] = None
179
213
  resource_service_publish_date: Optional[str] = None
180
214
  resource_default_locale: Optional[str] = None
@@ -218,6 +252,15 @@ class Collection(EDRCollection):
218
252
  )
219
253
  return v
220
254
 
255
+ @field_validator("description", "attribution")
256
+ @classmethod
257
+ def _strip_freetext(cls, v: str | None) -> str | None:
258
+ # YAML folded scalars ('>') append a trailing newline; strip it so it
259
+ # doesn't leak into the generated collection metadata.
260
+ if v is None:
261
+ return v
262
+ return v.strip()
263
+
221
264
 
222
265
  # ---------------------------------------------------------------------------
223
266
  # Enumerations (profile-specific; EDR data model enums live in edr-pydantic)
@@ -861,6 +904,17 @@ class ServiceProfile(BaseModel):
861
904
  "OpenAPI info block and the landing page response."
862
905
  ),
863
906
  )
907
+ attribution: str | None = Field(
908
+ default=None,
909
+ description=(
910
+ "Attribution / acknowledgement for the service or its data, per the "
911
+ "OGC API landing-page metadata (title/description/attribution). Surfaces "
912
+ "as a plain 'attribution' property in the landing page response and in "
913
+ "each collection object (collection-level overrides this profile-level "
914
+ "value). Aligns with EDR Part 3 ATS A.5, which inherits it from OGC API - "
915
+ "Common."
916
+ ),
917
+ )
864
918
  keywords: list[str] = Field(
865
919
  default_factory=list,
866
920
  description=(
@@ -900,6 +954,23 @@ class ServiceProfile(BaseModel):
900
954
  default=None,
901
955
  description="Default locale of the resource (e.g. 'eng'). Surfaces as info.x-default-locale.",
902
956
  )
957
+ default_language: str | None = Field(
958
+ default=None,
959
+ description=(
960
+ "Default value for the `lang` query parameter (e.g. 'en-US', 'en-GB', "
961
+ "'fr'). When unset, falls back to 'en-US'. Drives the `lang` parameter's "
962
+ "`default` in the generated OpenAPI."
963
+ ),
964
+ )
965
+ supported_languages: list[str] = Field(
966
+ default_factory=list,
967
+ description=(
968
+ "Languages the service supports for the `lang` query parameter. When set, "
969
+ "drives the `lang` parameter's `enum` in the generated OpenAPI (allowing "
970
+ "more than one language). When empty, the enum contains just the effective "
971
+ "default language."
972
+ ),
973
+ )
903
974
  collections: list[Collection] = Field(min_length=1)
904
975
  collection_examples: dict[str, dict] = Field(default_factory=dict)
905
976
  requirements: list[Requirement] = Field(default_factory=list)
@@ -1032,6 +1103,30 @@ class ServiceProfile(BaseModel):
1032
1103
  ),
1033
1104
  )
1034
1105
 
1106
+ @field_validator("description", "attribution")
1107
+ @classmethod
1108
+ def _strip_freetext(cls, v: str | None) -> str | None:
1109
+ # YAML folded scalars ('>') append a trailing newline; strip surrounding
1110
+ # whitespace so it doesn't leak into the OpenAPI/landing-page output.
1111
+ if v is None:
1112
+ return v
1113
+ return v.strip()
1114
+
1115
+ @property
1116
+ def effective_language(self) -> str:
1117
+ """Default `lang` value: explicit default_language, else 'en-US'."""
1118
+ return self.default_language or "en-US"
1119
+
1120
+ @property
1121
+ def effective_languages(self) -> list[str]:
1122
+ """Enum of `lang` values: supported_languages, else just the default."""
1123
+ if self.supported_languages:
1124
+ langs = list(dict.fromkeys(self.supported_languages))
1125
+ if self.effective_language not in langs:
1126
+ langs.insert(0, self.effective_language)
1127
+ return langs
1128
+ return [self.effective_language]
1129
+
1035
1130
  # OGC identifiers derived from name and spec_uri_base — not user-supplied directly
1036
1131
  @property
1037
1132
  def req_uri(self) -> str:
@@ -1127,7 +1222,7 @@ class ServiceProfile(BaseModel):
1127
1222
  def _check(label: str, value: str, constraint: "CrsConstraint | None") -> None:
1128
1223
  if constraint is None:
1129
1224
  return
1130
- if constraint.allowed is not None and value not in constraint.allowed:
1225
+ if constraint.allowed is not None and not _crs_in_allowed(value, constraint.allowed):
1131
1226
  raise ValueError(
1132
1227
  f"{label} '{value}' is not in allowed list {constraint.allowed}"
1133
1228
  )
@@ -1421,3 +1516,42 @@ def _compile_optional_pattern(label: str, pattern: str | None) -> re.Pattern | N
1421
1516
  return re.compile(pattern)
1422
1517
  except re.error as exc:
1423
1518
  raise ValueError(f"Invalid {label} regex '{pattern}': {exc}") from exc
1519
+
1520
+
1521
+ def _normalize_crs(value: str) -> str:
1522
+ """Normalize a CRS value for tolerant comparison.
1523
+
1524
+ URI CRS values are compared verbatim (after stripping). WKT2 CRS strings
1525
+ (e.g. GEOGCRS["WGS 84",...,ID["EPSG",4326]]) carry insignificant whitespace
1526
+ between tokens — after commas, around brackets — that differs by hand-
1527
+ editing but does not change meaning. Comparing such strings byte-for-byte
1528
+ makes an `allowed` list extremely fragile: a single space (e.g.
1529
+ '...ensemble", MEMBER[' vs '...ensemble",MEMBER[') fails the match.
1530
+
1531
+ For WKT-looking values (containing '[' and ']'), this collapses whitespace
1532
+ that sits OUTSIDE double-quoted substrings, so token spacing no longer
1533
+ matters while quoted names (e.g. "WGS 84") are preserved exactly.
1534
+ """
1535
+ v = value.strip()
1536
+ if "[" not in v or "]" not in v:
1537
+ return v
1538
+ out: list[str] = []
1539
+ in_quotes = False
1540
+ for ch in v:
1541
+ if ch == '"':
1542
+ in_quotes = not in_quotes
1543
+ out.append(ch)
1544
+ elif ch.isspace() and not in_quotes:
1545
+ # drop insignificant whitespace between WKT tokens
1546
+ continue
1547
+ else:
1548
+ out.append(ch)
1549
+ return "".join(out)
1550
+
1551
+
1552
+ def _crs_in_allowed(value: str, allowed: list[str]) -> bool:
1553
+ """True if *value* matches any entry in *allowed*, WKT-whitespace tolerant."""
1554
+ if value in allowed:
1555
+ return True
1556
+ nv = _normalize_crs(value)
1557
+ return any(nv == _normalize_crs(a) for a in allowed)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.7.5
3
+ Version: 3.9.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
@@ -520,6 +520,25 @@ extent_requirements:
520
520
 
521
521
  At least one of `extent_crs` or `supported_crs` is required. The same applies to TRS and VRS — all are optional individually but at least one CRS constraint must be present.
522
522
 
523
+ #### CRS as WKT2 strings — quote with single quotes
524
+
525
+ CRS/TRS values can be URIs (as above) **or** full WKT2 strings. A WKT2 string embeds double quotes, square brackets and commas — for example `GEOGCRS["WGS 84",...,ID["EPSG",4326]]`. YAML cannot parse this as a bare scalar or a **double-quoted** scalar (the inner `"` end the string early), so it must be wrapped in **single quotes**, or written as a block scalar:
526
+
527
+ ```yaml
528
+ # Single-quoted (recommended) — the whole WKT2 value in one line:
529
+ crs:
530
+ - 'GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]'
531
+
532
+ # or a block scalar (`|-` keeps it on multiple lines with no trailing newline):
533
+ extent_requirements:
534
+ extent_crs:
535
+ allowed:
536
+ - |-
537
+ GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",...,ID["EPSG",4326]]
538
+ ```
539
+
540
+ Double-quoting (`"GEOGCRS["WGS 84"...]"`) will fail to parse. If a config fails with a YAML parse error near a CRS line, this is almost always the cause — the CLI prints a hint pointing here. Publish the `wkt` conformance class (`.../conf/wkt`) in `required_conformance_classes` when your CRS values are WKT2.
541
+
523
542
  #### Vertical direction (`positive`)
524
543
 
525
544
  OGC API - EDR's vertical extent has no field for which way values increase. This is ambiguous for VRS like pressure levels, which different providers order top-to-bottom or bottom-to-top inconsistently (pressure increases as altitude *decreases*). Set `extent.vertical.positive` to `up` or `down` on a collection (following the CF Conventions `positive` attribute) to make the ordering explicit: