oapi-profile-builder 3.8.0__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.
- {oapi_profile_builder-3.8.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.9.0}/PKG-INFO +20 -1
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/README.md +19 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/pyproject.toml +1 -1
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cli.py +33 -1
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/generate.py +242 -64
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/models.py +139 -5
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0/src/oapi_profile_builder.egg-info}/PKG-INFO +20 -1
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/LICENSE +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/setup.cfg +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/__init__.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite_features.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/compile.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cover.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/server_validation.py +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
- {oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
{oapi_profile_builder-3.8.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.9.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oapi-profile-builder
|
|
3
|
-
Version: 3.
|
|
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
|
+
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
|
-
|
|
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
|
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/generate.py
RENAMED
|
@@ -46,6 +46,20 @@ def _const(val, version: str) -> dict:
|
|
|
46
46
|
return {"const": val}
|
|
47
47
|
return {"enum": [val], "default": val}
|
|
48
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}
|
|
62
|
+
|
|
49
63
|
def _contains(schema: dict, version: str) -> dict:
|
|
50
64
|
if version == "3.1.0":
|
|
51
65
|
return {"contains": schema}
|
|
@@ -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": "
|
|
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
|
-
"
|
|
209
|
+
"example": links_to_show,
|
|
180
210
|
},
|
|
181
|
-
"title": {"type": "string", **
|
|
182
|
-
"version": {"type": "string", **
|
|
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", **
|
|
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
|
-
**
|
|
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", **
|
|
198
|
-
if p.url: provider_props["url"] = {"type": "string", **
|
|
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", **
|
|
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[
|
|
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["
|
|
243
|
+
schema_props["classification"] = {
|
|
212
244
|
"type": "object",
|
|
213
245
|
"properties": {
|
|
214
|
-
"
|
|
215
|
-
"
|
|
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["
|
|
254
|
+
schema_props["metadata-date"] = {"type": "string", **_fixed(profile.metadata_date, version)}
|
|
221
255
|
if profile.resource_service_publish_date:
|
|
222
|
-
schema_props["
|
|
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:
|
|
@@ -688,14 +742,14 @@ def _collection_response_schema(coll: Collection,
|
|
|
688
742
|
output_formats_schema = {
|
|
689
743
|
"type": "array",
|
|
690
744
|
"items": {"type": "string"},
|
|
691
|
-
**
|
|
745
|
+
**_fixed(list(coll.output_formats), version)
|
|
692
746
|
}
|
|
693
747
|
|
|
694
748
|
if coll.crs:
|
|
695
749
|
crs_array_schema: dict = {
|
|
696
750
|
"type": "array",
|
|
697
751
|
"items": {"type": "string"},
|
|
698
|
-
**
|
|
752
|
+
**_fixed(list(coll.crs), version)
|
|
699
753
|
}
|
|
700
754
|
else:
|
|
701
755
|
crs_array_schema = {"type": "array", "items": supported_crs_schema}
|
|
@@ -706,42 +760,42 @@ def _collection_response_schema(coll: Collection,
|
|
|
706
760
|
extent_props["spatial"] = {
|
|
707
761
|
"type": "object", "required": ["bbox", "crs"],
|
|
708
762
|
"properties": {
|
|
709
|
-
"bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **
|
|
710
|
-
"crs": {"type": "string", **
|
|
763
|
+
"bbox": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, **_fixed(s.bbox, version)},
|
|
764
|
+
"crs": {"type": "string", **_fixed(s.crs, version)}
|
|
711
765
|
}
|
|
712
766
|
}
|
|
713
767
|
if coll.extent and coll.extent.temporal:
|
|
714
768
|
t = coll.extent.temporal
|
|
715
|
-
temporal_props = {"trs": {"type": "string", **
|
|
769
|
+
temporal_props = {"trs": {"type": "string", **_fixed(t.trs, version)}}
|
|
716
770
|
if t.interval:
|
|
717
771
|
temporal_props["interval"] = {
|
|
718
772
|
"type": "array",
|
|
719
773
|
"items": {"type": "array", "items": {"type": "string", "nullable": True}},
|
|
720
|
-
**
|
|
774
|
+
**_fixed(t.interval, version)
|
|
721
775
|
}
|
|
722
776
|
if t.values:
|
|
723
777
|
temporal_props["values"] = {
|
|
724
778
|
"type": "array",
|
|
725
779
|
"items": {"type": "string"},
|
|
726
|
-
**
|
|
780
|
+
**_fixed(t.values, version)
|
|
727
781
|
}
|
|
728
782
|
extent_props["temporal"] = {"type": "object", "required": ["trs"], "properties": temporal_props}
|
|
729
783
|
if coll.extent and coll.extent.vertical:
|
|
730
784
|
v = coll.extent.vertical
|
|
731
|
-
vertical_props = {"vrs": {"type": "string", **
|
|
785
|
+
vertical_props = {"vrs": {"type": "string", **_fixed(v.vrs, version)}}
|
|
732
786
|
if v.interval:
|
|
733
787
|
vertical_props["interval"] = {
|
|
734
788
|
"type": "array",
|
|
735
789
|
"items": {"type": "array", "items": {"type": "number"}},
|
|
736
|
-
**
|
|
790
|
+
**_fixed(v.interval, version)
|
|
737
791
|
}
|
|
738
792
|
if v.values:
|
|
739
793
|
vertical_props["values"] = {
|
|
740
794
|
"type": "array",
|
|
741
795
|
"items": {"type": "string"},
|
|
742
|
-
**
|
|
796
|
+
**_fixed(v.values, version)
|
|
743
797
|
}
|
|
744
|
-
if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **
|
|
798
|
+
if getattr(v, "positive", None): vertical_props["positive"] = {"type": "string", **_fixed(v.positive, version)}
|
|
745
799
|
extent_props["vertical"] = {"type": "object", "required": ["vrs"], "properties": vertical_props}
|
|
746
800
|
if coll.extent and coll.extent.custom:
|
|
747
801
|
custom_items = []
|
|
@@ -750,9 +804,9 @@ def _collection_response_schema(coll: Collection,
|
|
|
750
804
|
"type": "object",
|
|
751
805
|
"required": ["id", "interval", "reference"],
|
|
752
806
|
"properties": {
|
|
753
|
-
"id": {"type": "string", **
|
|
754
|
-
"interval": {"type": "array", "items": {"type": "string"}, **
|
|
755
|
-
"reference": {"type": "string", **
|
|
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)},
|
|
756
810
|
}
|
|
757
811
|
})
|
|
758
812
|
extent_props["custom"] = {"type": "array", "items": {"oneOf": custom_items}}
|
|
@@ -766,15 +820,15 @@ def _collection_response_schema(coll: Collection,
|
|
|
766
820
|
if vars_obj:
|
|
767
821
|
for f in ("query_type", "default_output_format"):
|
|
768
822
|
v = getattr(vars_obj, f, None);
|
|
769
|
-
if v: vars_props[f] = {"type": "string", **
|
|
823
|
+
if v: vars_props[f] = {"type": "string", **_fixed(v, version)}
|
|
770
824
|
if getattr(vars_obj, "output_formats", None):
|
|
771
|
-
vars_props["output_formats"] = {"type": "array", "items": {"type": "string",
|
|
825
|
+
vars_props["output_formats"] = {"type": "array", "items": {"type": "string"}, **_fixed(list(vars_obj.output_formats), version)}
|
|
772
826
|
extra = getattr(vars_obj, "model_extra", {}) or {}
|
|
773
|
-
if "crs" in extra: vars_props["crs"] = {"type": "array", "items": {"type": "string"}, **
|
|
774
|
-
if "crs_details" in extra: vars_props["crs_details"] = {"type": "array", **
|
|
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)}
|
|
775
829
|
dq_props[dq_name] = {"type": "object", "properties": {"link": {"type": "object", "properties": {
|
|
776
|
-
"href": {"type": "string", **
|
|
777
|
-
"rel": {"type": "string", **
|
|
830
|
+
"href": {"type": "string", **_fixed(dq_val.link.href, version)},
|
|
831
|
+
"rel": {"type": "string", **_fixed(dq_val.link.rel, version)},
|
|
778
832
|
"variables": {"type": "object", "properties": vars_props}
|
|
779
833
|
}}}}
|
|
780
834
|
|
|
@@ -787,28 +841,61 @@ def _collection_response_schema(coll: Collection,
|
|
|
787
841
|
if getattr(l, "hreflang", None): link_obj["hreflang"] = l.hreflang
|
|
788
842
|
links_to_show.append(link_obj)
|
|
789
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
|
+
|
|
790
896
|
schema: dict = {
|
|
791
897
|
"type": "object", "required": ["id", "links", "extent"],
|
|
792
|
-
"properties":
|
|
793
|
-
"id": {"type": "string", **_const(coll.id, version)},
|
|
794
|
-
"title": {"type": "string", **_const(coll.title, version)} if coll.title else {"type": "string"},
|
|
795
|
-
"description": {"type": "string", **_const(coll.description, version)} if coll.description else {"type": "string"},
|
|
796
|
-
"keywords": {
|
|
797
|
-
"type": "array",
|
|
798
|
-
"items": {"type": "string"},
|
|
799
|
-
**(_const(coll.keywords, version) if coll.keywords else {})
|
|
800
|
-
},
|
|
801
|
-
"links": {
|
|
802
|
-
"type": "array",
|
|
803
|
-
"items": _LINK_SCHEMA,
|
|
804
|
-
**_const(links_to_show, version)
|
|
805
|
-
},
|
|
806
|
-
"crs": crs_array_schema,
|
|
807
|
-
"output_formats": output_formats_schema,
|
|
808
|
-
"parameter_names": {**param_names_schema, "additionalProperties": False},
|
|
809
|
-
"extent": {"type": "object", "required": ["spatial"], "properties": extent_props},
|
|
810
|
-
"data_queries": {"type": "object", "properties": dq_props, "additionalProperties": False}
|
|
811
|
-
}
|
|
898
|
+
"properties": coll_props,
|
|
812
899
|
}
|
|
813
900
|
return {"description": "Collection metadata", "content": {"application/json": {"schema": schema}}}
|
|
814
901
|
def _f_enum(profile: "ServiceProfile | None") -> list[str]:
|
|
@@ -1132,6 +1219,95 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
|
|
|
1132
1219
|
return paths
|
|
1133
1220
|
|
|
1134
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
|
+
|
|
1135
1311
|
def _core_paths(profile: ServiceProfile) -> dict:
|
|
1136
1312
|
version = profile.openapi_version
|
|
1137
1313
|
landing_response = _landing_page_schema(profile)
|
|
@@ -1172,7 +1348,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
|
|
|
1172
1348
|
"operationId": "getCollections",
|
|
1173
1349
|
"tags": ["server"],
|
|
1174
1350
|
"parameters": [_F, _LANG],
|
|
1175
|
-
"responses": {"200":
|
|
1351
|
+
"responses": {"200": _collections_response_schema(profile), "400": _ERR_400, "500": _ERR_500},
|
|
1176
1352
|
}},
|
|
1177
1353
|
"/api": {"get": {
|
|
1178
1354
|
"summary": "OpenAPI definition",
|
|
@@ -1180,7 +1356,7 @@ def _core_paths(profile: ServiceProfile) -> dict:
|
|
|
1180
1356
|
"operationId": "getOpenAPI",
|
|
1181
1357
|
"tags": ["server"],
|
|
1182
1358
|
"parameters": [_F],
|
|
1183
|
-
"responses": {"200":
|
|
1359
|
+
"responses": {"200": _api_response_schema(profile), "default": _ERR_DEFAULT},
|
|
1184
1360
|
}},
|
|
1185
1361
|
}
|
|
1186
1362
|
|
|
@@ -1352,10 +1528,12 @@ def build_openapi(profile: ServiceProfile) -> dict:
|
|
|
1352
1528
|
if profile.classification:
|
|
1353
1529
|
info["x-classification"] = {
|
|
1354
1530
|
k: v for k, v in (
|
|
1355
|
-
("
|
|
1356
|
-
("
|
|
1531
|
+
("classification_level", profile.classification.classification_level),
|
|
1532
|
+
("classification_system", profile.classification.classification_system),
|
|
1357
1533
|
) if v
|
|
1358
1534
|
}
|
|
1535
|
+
if profile.attribution:
|
|
1536
|
+
info["x-attribution"] = profile.attribution
|
|
1359
1537
|
if profile.metadata_date:
|
|
1360
1538
|
info["x-metadata-date"] = profile.metadata_date
|
|
1361
1539
|
if profile.resource_service_publish_date:
|
|
@@ -1392,7 +1570,7 @@ def build_openapi(profile: ServiceProfile) -> dict:
|
|
|
1392
1570
|
"lang": {
|
|
1393
1571
|
"name": "lang", "in": "query", "required": False,
|
|
1394
1572
|
"description": "The optional lang parameter instructs the server return a response in a certain language, if supported.",
|
|
1395
|
-
"schema": {"type": "string", "default":
|
|
1573
|
+
"schema": {"type": "string", "default": profile.effective_language, "enum": profile.effective_languages},
|
|
1396
1574
|
},
|
|
1397
1575
|
},
|
|
1398
1576
|
"responses": {
|
|
@@ -2360,7 +2538,7 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
|
|
|
2360
2538
|
classification_banner = ""
|
|
2361
2539
|
if profile.classification:
|
|
2362
2540
|
cl = profile.classification
|
|
2363
|
-
label = cl.
|
|
2541
|
+
label = cl.classification_level + (f" ({cl.classification_system})" if cl.classification_system else "")
|
|
2364
2542
|
classification_banner = f"*SECURITY CLASSIFICATION: {label}*\n\n"
|
|
2365
2543
|
|
|
2366
2544
|
# --- Point of contact paragraph (from provider) ---
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/models.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
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)
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0/src/oapi_profile_builder.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oapi-profile-builder
|
|
3
|
-
Version: 3.
|
|
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:
|
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite_features.py
RENAMED
|
File without changes
|
{oapi_profile_builder-3.8.0 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/compile.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|