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.
- {oapi_profile_builder-3.7.5/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.9.0}/PKG-INFO +20 -1
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/README.md +19 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/pyproject.toml +1 -1
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cli.py +33 -1
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/generate.py +255 -58
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/models.py +139 -5
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0/src/oapi_profile_builder.egg-info}/PKG-INFO +20 -1
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/LICENSE +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/setup.cfg +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/__init__.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite_features.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/compile.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cover.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/server_validation.py +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
- {oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
{oapi_profile_builder-3.7.5/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.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/generate.py
RENAMED
|
@@ -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": "
|
|
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:
|
|
@@ -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
|
|
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:
|
|
691
|
-
|
|
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"}}, **
|
|
700
|
-
"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)}
|
|
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", **
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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", **
|
|
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
|
-
**
|
|
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
|
-
**
|
|
796
|
+
**_fixed(v.values, version)
|
|
733
797
|
}
|
|
734
|
-
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)}
|
|
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", **
|
|
744
|
-
"interval": {"type": "array", "items": {"type": "string"}, **
|
|
745
|
-
"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)},
|
|
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", **
|
|
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",
|
|
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"}, **
|
|
764
|
-
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)}
|
|
765
829
|
dq_props[dq_name] = {"type": "object", "properties": {"link": {"type": "object", "properties": {
|
|
766
|
-
"href": {"type": "string", **
|
|
767
|
-
"rel": {"type": "string", **
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
("
|
|
1337
|
-
("
|
|
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":
|
|
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.
|
|
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) ---
|
{oapi_profile_builder-3.7.5 → 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.7.5 → 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.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{oapi_profile_builder-3.7.5 → oapi_profile_builder-3.9.0}/src/oapi_profile_builder/cite_features.py
RENAMED
|
File without changes
|
{oapi_profile_builder-3.7.5 → 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
|