oapi-profile-builder 3.0.0__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {oapi_profile_builder-3.0.0/src/oapi_profile_builder.egg-info → oapi_profile_builder-3.1.0}/PKG-INFO +113 -8
  2. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/README.md +112 -7
  3. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/pyproject.toml +1 -1
  4. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/cli.py +1 -1
  5. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/generate.py +231 -36
  6. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/models.py +212 -10
  7. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0/src/oapi_profile_builder.egg-info}/PKG-INFO +113 -8
  8. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/LICENSE +0 -0
  9. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/setup.cfg +0 -0
  10. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/__init__.py +0 -0
  11. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/cite.py +0 -0
  12. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/cite_features.py +0 -0
  13. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/compile.py +0 -0
  14. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder/server_validation.py +0 -0
  15. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder.egg-info/SOURCES.txt +0 -0
  16. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder.egg-info/dependency_links.txt +0 -0
  17. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder.egg-info/entry_points.txt +0 -0
  18. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder.egg-info/requires.txt +0 -0
  19. {oapi_profile_builder-3.0.0 → oapi_profile_builder-3.1.0}/src/oapi_profile_builder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.0.0
3
+ Version: 3.1.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
@@ -290,12 +290,18 @@ abstract_tests: []
290
290
  | `description` | string | no | Human-readable description of the service profile. Surfaces in the OpenAPI `info.description` and the landing page response schema |
291
291
  | `keywords` | list | no | Service-level keywords (e.g. query types, parameter names, domain terms). Surfaces in `info.x-keywords` and the landing page response schema. Distinct from `document_metadata.keywords`, which are for the OGC PDF header |
292
292
  | `server_url` | string | no | Documentation only — not written to the profile OpenAPI |
293
+ | `provider` | object | no | Service provider / responsible party. Surfaces in OpenAPI `info.contact` and the document Point of Contact (see below) |
294
+ | `classification` | object | no | Security classification (`level` + `system`). Surfaces as a document banner and `info.x-classification` |
295
+ | `metadata_date` | string | no | Metadata update date (ISO 8601). Surfaces as `info.x-metadata-date` |
296
+ | `resource_service_publish_date` | string | no | Resource/service publication date (ISO 8601). Surfaces as `info.x-resource-publish-date` |
297
+ | `resource_default_locale` | string | no | Default resource locale (e.g. `eng`). Surfaces as `info.x-default-locale` |
293
298
  | `allow_post_queries` | bool | no | When `true`, generates `POST` alongside `GET` for all EDR data query endpoints. Defaults to `false` |
294
299
  | `collections` | list | yes | One or more EDR collections (see below) |
295
300
  | `required_conformance_classes` | list | no | Conformance classes implementations must declare. Defaults to EDR Core |
296
301
  | `extent_requirements` | object | no | Profile-level CRS/TRS/VRS constraints (see below) |
297
302
  | `output_formats` | list | no | Format name → media type + schema ref mappings |
298
303
  | `collection_id_pattern` | string | no | Regex all collection IDs must match |
304
+ | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
299
305
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
300
306
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
301
307
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
@@ -303,7 +309,7 @@ abstract_tests: []
303
309
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
304
310
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
305
311
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
306
- | `document_metadata` | object | no | Metanorma PDF header (doc number, editors, orgs, keywords) |
312
+ | `document_metadata` | object | no | Metanorma PDF header — doc number/type/subtype, status, editors, submitters, dates, notice, normative references (see below) |
307
313
 
308
314
  ---
309
315
 
@@ -360,6 +366,22 @@ data_queries:
360
366
  crs_details:
361
367
  - crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
362
368
  - crs: "http://www.opengis.net/def/crs/EPSG/0/4326"
369
+ cube:
370
+ link:
371
+ href: https://example.com/collections/obs/cube
372
+ rel: data
373
+ variables:
374
+ query_type: cube
375
+ output_formats: [CoverageJSON, GeoTIFF]
376
+ # default_output_format: response format used when the `f` query
377
+ # parameter is omitted. Must be one of output_formats. Sets the
378
+ # `default` on the generated OpenAPI `f` parameter for this query.
379
+ default_output_format: GeoTIFF
380
+ # crs: shorthand for crs_details — a plain list of CRS URIs.
381
+ # Validated against extent_requirements.supported_crs.
382
+ crs:
383
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
384
+ - "http://www.opengis.net/def/crs/EPSG/0/4326"
363
385
  ```
364
386
 
365
387
  #### `parameter_names` example
@@ -536,16 +558,39 @@ pubsub:
536
558
 
537
559
  ### `document_metadata`
538
560
 
539
- Required only when compiling a PDF with `--pdf`.
561
+ Required only when compiling a PDF with `--pdf`. Fields map to Metanorma OGC document attributes.
540
562
 
541
563
  ```yaml
542
564
  document_metadata:
543
565
  doc_number: "25-myprofile"
544
- doc_subtype: implementation # implementation | best-practice | engineering-report
566
+ doc_type: draft-standard # Metanorma :doctype: (draft-standard | standard | best-practice | engineering-report | ...)
567
+ doc_subtype: implementation # implementation | best-practice | engineering-report | profile
568
+ status: approved # Metanorma :status:/:docstage: (swg-draft | public-rfc | approved | ...)
545
569
  editors:
546
570
  - Jane Smith
571
+ # submitters: structured table for page iv — each row carries its own affiliation.
572
+ # When provided, takes precedence over the editors/submitting_orgs fallback.
573
+ submitters:
574
+ - name: Jane Smith
575
+ affiliation: My Organization
576
+ role: editor
577
+ - name: Standards Working Group
578
+ affiliation: My SDO
547
579
  submitting_orgs:
548
580
  - My Organization
581
+ # Title-page dates (YYYY-MM-DD). Omitted dates fall back to {copyright_year}-01-01.
582
+ submission_date: "2026-05-01" # → :received-date:
583
+ approval_date: "2026-06-15" # → :issued-date:
584
+ publication_date: "2026-07-01" # → :published-date:
585
+ # Custom notice paragraph rendered in the front matter. Note: the OGC cover-page
586
+ # legal notice itself is generated by Metanorma from doc_type/status.
587
+ notice: >
588
+ This is a DRAFT standard and is subject to change.
589
+ # Additional normative references appended to the References section.
590
+ normative_references:
591
+ - anchor: DGIWG-250
592
+ citation: DGIWG 250
593
+ title: DGIWG GeoTIFF Profile
549
594
  keywords:
550
595
  - ogcdoc
551
596
  - OGC API
@@ -554,6 +599,64 @@ document_metadata:
554
599
  external_id: http://www.opengis.net/doc/dp/my-profile/1.0
555
600
  ```
556
601
 
602
+ The cover-page sub-title (e.g. "Draft Standard — Implementation — Approved") is produced by Metanorma from the `doc_type`, `doc_subtype`, and `status` combination.
603
+
604
+ ---
605
+
606
+ ### `provider`, `classification`, and provenance metadata
607
+
608
+ Optional service-level metadata. Each may also be set per-collection (the collection value overrides the profile root for that collection and is round-tripped into `profile_config.json`).
609
+
610
+ ```yaml
611
+ provider:
612
+ name: Austrian Military
613
+ url: "https://www.bmlv.gv.at/english/forces/organisation.shtml"
614
+ contact:
615
+ email: presse@bmlvs.gv.at
616
+ phone: "+43 ..."
617
+ hours: Monday to Friday 08:00 - 17:00
618
+ instructions: During hours of Service
619
+ address: "Ministry of Defence, Rossauer Lände 1"
620
+ postalcode: A-1090
621
+ city: Vienna
622
+ country: Austria
623
+
624
+ classification:
625
+ level: "NATO RESTRICTED (NR)"
626
+ system: NATO
627
+
628
+ metadata_date: "2026-06-25T07:31Z"
629
+ resource_service_publish_date: "2026-06-24T14:10Z"
630
+ resource_default_locale: eng
631
+ ```
632
+
633
+ Where these surface:
634
+
635
+ - `provider` → OpenAPI `info.contact` (`name`/`url`/`email`; the remaining contact fields become `x-` extensions) and a **Point of Contact** section in the generated document.
636
+ - `classification` → a **security classification banner** in the document Abstract and `info.x-classification` in the OpenAPI.
637
+ - `metadata_date` / `resource_service_publish_date` / `resource_default_locale` → `info.x-metadata-date` / `info.x-resource-publish-date` / `info.x-default-locale`.
638
+
639
+ > The profile OpenAPI is implementation-independent (per EDR Part 3 REQ_publishing), so these describe the profile/data provenance rather than a specific deployment endpoint.
640
+
641
+ ---
642
+
643
+ ### `spec_uri_base`
644
+
645
+ By default, the requirements class and conformance class identifiers are published under the OGC API - EDR Part 3 namespace:
646
+
647
+ ```
648
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/req/<name>
649
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/conf/<name>
650
+ ```
651
+
652
+ Set `spec_uri_base` to publish under a different SDO namespace. This updates the OpenAPI `x-ogc-profile` link, the requirements/conformance AsciiDoc identifiers, and the conformance section:
653
+
654
+ ```yaml
655
+ spec_uri_base: "https://schemas.dgiwg.org/edr/1.0"
656
+ # → https://schemas.dgiwg.org/edr/1.0/req/<name>
657
+ # → https://schemas.dgiwg.org/edr/1.0/conf/<name>
658
+ ```
659
+
557
660
  ---
558
661
 
559
662
  ## Validation Rules
@@ -564,10 +667,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
564
667
  |---|---|
565
668
  | `name` format | Must match `^[a-z0-9_]+$` |
566
669
  | No duplicate collection IDs | Across the whole profile |
567
- | `extent_requirements` requires CRS spec | Either `allowed_crs` or `crs_pattern` must be set |
568
- | Collection CRS validated | Each `extent.spatial.crs` checked against `allowed_crs`/`crs_pattern` |
569
- | Collection TRS validated | Each `extent.temporal.trs` checked against `allowed_trs`/`trs_pattern` |
570
- | `crs_details` validated | Each `data_queries.*.variables.crs_details[].crs` checked against CRS constraints |
670
+ | `extent_requirements` requires CRS spec | At least one of `extent_crs` or `supported_crs` must be set (each uses `allowed` or `pattern`) |
671
+ | Collection CRS validated | Each `extent.spatial.crs` checked against `extent_crs`; `crs[]` and `crs_details` checked against `supported_crs` |
672
+ | Collection TRS validated | Each `extent.temporal.trs` checked against `extent_trs` |
673
+ | `crs_details` / `crs` validated | Each `data_queries.*.variables.crs_details[].crs` and `crs[]` checked against `supported_crs` |
674
+ | `default_output_format` validated | Each `data_queries.*.variables.default_output_format` must be one of that query's `output_formats` |
675
+ | Document dates format | `submission_date` / `approval_date` / `publication_date` must be `YYYY-MM-DD` |
571
676
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
572
677
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
573
678
  | `collection_id_pattern` enforced | All collection IDs must match if set |
@@ -245,12 +245,18 @@ abstract_tests: []
245
245
  | `description` | string | no | Human-readable description of the service profile. Surfaces in the OpenAPI `info.description` and the landing page response schema |
246
246
  | `keywords` | list | no | Service-level keywords (e.g. query types, parameter names, domain terms). Surfaces in `info.x-keywords` and the landing page response schema. Distinct from `document_metadata.keywords`, which are for the OGC PDF header |
247
247
  | `server_url` | string | no | Documentation only — not written to the profile OpenAPI |
248
+ | `provider` | object | no | Service provider / responsible party. Surfaces in OpenAPI `info.contact` and the document Point of Contact (see below) |
249
+ | `classification` | object | no | Security classification (`level` + `system`). Surfaces as a document banner and `info.x-classification` |
250
+ | `metadata_date` | string | no | Metadata update date (ISO 8601). Surfaces as `info.x-metadata-date` |
251
+ | `resource_service_publish_date` | string | no | Resource/service publication date (ISO 8601). Surfaces as `info.x-resource-publish-date` |
252
+ | `resource_default_locale` | string | no | Default resource locale (e.g. `eng`). Surfaces as `info.x-default-locale` |
248
253
  | `allow_post_queries` | bool | no | When `true`, generates `POST` alongside `GET` for all EDR data query endpoints. Defaults to `false` |
249
254
  | `collections` | list | yes | One or more EDR collections (see below) |
250
255
  | `required_conformance_classes` | list | no | Conformance classes implementations must declare. Defaults to EDR Core |
251
256
  | `extent_requirements` | object | no | Profile-level CRS/TRS/VRS constraints (see below) |
252
257
  | `output_formats` | list | no | Format name → media type + schema ref mappings |
253
258
  | `collection_id_pattern` | string | no | Regex all collection IDs must match |
259
+ | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
254
260
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
255
261
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
256
262
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
@@ -258,7 +264,7 @@ abstract_tests: []
258
264
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
259
265
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
260
266
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
261
- | `document_metadata` | object | no | Metanorma PDF header (doc number, editors, orgs, keywords) |
267
+ | `document_metadata` | object | no | Metanorma PDF header — doc number/type/subtype, status, editors, submitters, dates, notice, normative references (see below) |
262
268
 
263
269
  ---
264
270
 
@@ -315,6 +321,22 @@ data_queries:
315
321
  crs_details:
316
322
  - crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
317
323
  - crs: "http://www.opengis.net/def/crs/EPSG/0/4326"
324
+ cube:
325
+ link:
326
+ href: https://example.com/collections/obs/cube
327
+ rel: data
328
+ variables:
329
+ query_type: cube
330
+ output_formats: [CoverageJSON, GeoTIFF]
331
+ # default_output_format: response format used when the `f` query
332
+ # parameter is omitted. Must be one of output_formats. Sets the
333
+ # `default` on the generated OpenAPI `f` parameter for this query.
334
+ default_output_format: GeoTIFF
335
+ # crs: shorthand for crs_details — a plain list of CRS URIs.
336
+ # Validated against extent_requirements.supported_crs.
337
+ crs:
338
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
339
+ - "http://www.opengis.net/def/crs/EPSG/0/4326"
318
340
  ```
319
341
 
320
342
  #### `parameter_names` example
@@ -491,16 +513,39 @@ pubsub:
491
513
 
492
514
  ### `document_metadata`
493
515
 
494
- Required only when compiling a PDF with `--pdf`.
516
+ Required only when compiling a PDF with `--pdf`. Fields map to Metanorma OGC document attributes.
495
517
 
496
518
  ```yaml
497
519
  document_metadata:
498
520
  doc_number: "25-myprofile"
499
- doc_subtype: implementation # implementation | best-practice | engineering-report
521
+ doc_type: draft-standard # Metanorma :doctype: (draft-standard | standard | best-practice | engineering-report | ...)
522
+ doc_subtype: implementation # implementation | best-practice | engineering-report | profile
523
+ status: approved # Metanorma :status:/:docstage: (swg-draft | public-rfc | approved | ...)
500
524
  editors:
501
525
  - Jane Smith
526
+ # submitters: structured table for page iv — each row carries its own affiliation.
527
+ # When provided, takes precedence over the editors/submitting_orgs fallback.
528
+ submitters:
529
+ - name: Jane Smith
530
+ affiliation: My Organization
531
+ role: editor
532
+ - name: Standards Working Group
533
+ affiliation: My SDO
502
534
  submitting_orgs:
503
535
  - My Organization
536
+ # Title-page dates (YYYY-MM-DD). Omitted dates fall back to {copyright_year}-01-01.
537
+ submission_date: "2026-05-01" # → :received-date:
538
+ approval_date: "2026-06-15" # → :issued-date:
539
+ publication_date: "2026-07-01" # → :published-date:
540
+ # Custom notice paragraph rendered in the front matter. Note: the OGC cover-page
541
+ # legal notice itself is generated by Metanorma from doc_type/status.
542
+ notice: >
543
+ This is a DRAFT standard and is subject to change.
544
+ # Additional normative references appended to the References section.
545
+ normative_references:
546
+ - anchor: DGIWG-250
547
+ citation: DGIWG 250
548
+ title: DGIWG GeoTIFF Profile
504
549
  keywords:
505
550
  - ogcdoc
506
551
  - OGC API
@@ -509,6 +554,64 @@ document_metadata:
509
554
  external_id: http://www.opengis.net/doc/dp/my-profile/1.0
510
555
  ```
511
556
 
557
+ The cover-page sub-title (e.g. "Draft Standard — Implementation — Approved") is produced by Metanorma from the `doc_type`, `doc_subtype`, and `status` combination.
558
+
559
+ ---
560
+
561
+ ### `provider`, `classification`, and provenance metadata
562
+
563
+ Optional service-level metadata. Each may also be set per-collection (the collection value overrides the profile root for that collection and is round-tripped into `profile_config.json`).
564
+
565
+ ```yaml
566
+ provider:
567
+ name: Austrian Military
568
+ url: "https://www.bmlv.gv.at/english/forces/organisation.shtml"
569
+ contact:
570
+ email: presse@bmlvs.gv.at
571
+ phone: "+43 ..."
572
+ hours: Monday to Friday 08:00 - 17:00
573
+ instructions: During hours of Service
574
+ address: "Ministry of Defence, Rossauer Lände 1"
575
+ postalcode: A-1090
576
+ city: Vienna
577
+ country: Austria
578
+
579
+ classification:
580
+ level: "NATO RESTRICTED (NR)"
581
+ system: NATO
582
+
583
+ metadata_date: "2026-06-25T07:31Z"
584
+ resource_service_publish_date: "2026-06-24T14:10Z"
585
+ resource_default_locale: eng
586
+ ```
587
+
588
+ Where these surface:
589
+
590
+ - `provider` → OpenAPI `info.contact` (`name`/`url`/`email`; the remaining contact fields become `x-` extensions) and a **Point of Contact** section in the generated document.
591
+ - `classification` → a **security classification banner** in the document Abstract and `info.x-classification` in the OpenAPI.
592
+ - `metadata_date` / `resource_service_publish_date` / `resource_default_locale` → `info.x-metadata-date` / `info.x-resource-publish-date` / `info.x-default-locale`.
593
+
594
+ > The profile OpenAPI is implementation-independent (per EDR Part 3 REQ_publishing), so these describe the profile/data provenance rather than a specific deployment endpoint.
595
+
596
+ ---
597
+
598
+ ### `spec_uri_base`
599
+
600
+ By default, the requirements class and conformance class identifiers are published under the OGC API - EDR Part 3 namespace:
601
+
602
+ ```
603
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/req/<name>
604
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/conf/<name>
605
+ ```
606
+
607
+ Set `spec_uri_base` to publish under a different SDO namespace. This updates the OpenAPI `x-ogc-profile` link, the requirements/conformance AsciiDoc identifiers, and the conformance section:
608
+
609
+ ```yaml
610
+ spec_uri_base: "https://schemas.dgiwg.org/edr/1.0"
611
+ # → https://schemas.dgiwg.org/edr/1.0/req/<name>
612
+ # → https://schemas.dgiwg.org/edr/1.0/conf/<name>
613
+ ```
614
+
512
615
  ---
513
616
 
514
617
  ## Validation Rules
@@ -519,10 +622,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
519
622
  |---|---|
520
623
  | `name` format | Must match `^[a-z0-9_]+$` |
521
624
  | No duplicate collection IDs | Across the whole profile |
522
- | `extent_requirements` requires CRS spec | Either `allowed_crs` or `crs_pattern` must be set |
523
- | Collection CRS validated | Each `extent.spatial.crs` checked against `allowed_crs`/`crs_pattern` |
524
- | Collection TRS validated | Each `extent.temporal.trs` checked against `allowed_trs`/`trs_pattern` |
525
- | `crs_details` validated | Each `data_queries.*.variables.crs_details[].crs` checked against CRS constraints |
625
+ | `extent_requirements` requires CRS spec | At least one of `extent_crs` or `supported_crs` must be set (each uses `allowed` or `pattern`) |
626
+ | Collection CRS validated | Each `extent.spatial.crs` checked against `extent_crs`; `crs[]` and `crs_details` checked against `supported_crs` |
627
+ | Collection TRS validated | Each `extent.temporal.trs` checked against `extent_trs` |
628
+ | `crs_details` / `crs` validated | Each `data_queries.*.variables.crs_details[].crs` and `crs[]` checked against `supported_crs` |
629
+ | `default_output_format` validated | Each `data_queries.*.variables.default_output_format` must be one of that query's `output_formats` |
630
+ | Document dates format | `submission_date` / `approval_date` / `publication_date` must be `YYYY-MM-DD` |
526
631
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
527
632
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
528
633
  | `collection_id_pattern` enforced | All collection IDs must match if set |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oapi-profile-builder"
7
- version = "3.0.0"
7
+ version = "3.1.0"
8
8
  description = "Authoritative tooling for creating OGC API Service Profiles (EDR, Features)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -169,7 +169,7 @@ def main() -> None:
169
169
 
170
170
  def _run_validate_server(args) -> None:
171
171
  import re
172
- from ogc_edr_profile.server_validation import validate_server
172
+ from oapi_profile_builder.server_validation import validate_server
173
173
 
174
174
  if args.openapi:
175
175
  import yaml
@@ -328,6 +328,17 @@ _POST_BODY_SCHEMAS: dict[str, dict] = {
328
328
  def _post_operation(get_op: dict, query_type: str, coll_id: str, responses: dict) -> dict:
329
329
  """Build a POST operation mirroring a GET EDR data query operation."""
330
330
  body_schema = _POST_BODY_SCHEMAS.get(query_type, {"type": "object"})
331
+ # Carry over a default output format from the GET operation's `f` parameter
332
+ # so the POST body documents the same default.
333
+ default_f = None
334
+ for p in get_op.get("parameters", []):
335
+ if isinstance(p, dict) and p.get("name") == "f":
336
+ default_f = p.get("schema", {}).get("default")
337
+ break
338
+ if default_f and "properties" in body_schema and "f" in body_schema["properties"]:
339
+ import copy
340
+ body_schema = copy.deepcopy(body_schema)
341
+ body_schema["properties"]["f"]["default"] = default_f
331
342
  return {
332
343
  "summary": get_op["summary"].replace("query", "query (POST)"),
333
344
  "description": get_op.get("description", ""),
@@ -583,6 +594,15 @@ def _collection_response_schema(coll: Collection,
583
594
  "type": "array",
584
595
  "items": {"type": "string"},
585
596
  },
597
+ "default_output_format": {
598
+ "type": "string",
599
+ "description": "Default output format for this data query when f is omitted",
600
+ },
601
+ "crs": {
602
+ "type": "array",
603
+ "description": "CRS values supported by this data query (shorthand for crs_details)",
604
+ "items": {"type": "string"},
605
+ },
586
606
  "crs_details": {
587
607
  "type": "array",
588
608
  "description": "CRS values supported by this data query",
@@ -665,12 +685,79 @@ def _parameter_schema() -> dict:
665
685
  }
666
686
 
667
687
 
688
+ def _f_enum(profile: "ServiceProfile | None") -> list[str]:
689
+ """Allowed values for the `f` (output format) parameter: media types."""
690
+ enum = ["json", "html"]
691
+ if profile:
692
+ for fmt in profile.output_formats:
693
+ if fmt.media_type not in enum:
694
+ enum.append(fmt.media_type)
695
+ return enum
696
+
697
+
698
+ def _format_media_type(profile: "ServiceProfile | None", format_name: str) -> str:
699
+ """Resolve a format name (e.g. 'GeoTIFF') to its media type, falling back to the name."""
700
+ if profile:
701
+ for fmt in profile.output_formats:
702
+ if fmt.name == format_name:
703
+ return fmt.media_type
704
+ return format_name
705
+
706
+
707
+ def _query_default_format(variables: object) -> str | None:
708
+ """Read default_output_format from an EDR query's variables.
709
+
710
+ edr-pydantic's Variables defines default_output_format as a real field;
711
+ fall back to model_extra for forward compatibility.
712
+ """
713
+ val = getattr(variables, "default_output_format", None)
714
+ if val is None:
715
+ extra = getattr(variables, "model_extra", None) or {}
716
+ val = extra.get("default_output_format")
717
+ return val
718
+
719
+
720
+ def _f_param(profile: "ServiceProfile | None", variables: object | None) -> dict:
721
+ """Return the `f` parameter for a query.
722
+
723
+ When the query's variables declare a default_output_format, an inline `f`
724
+ parameter is returned carrying that media type as its `default`. Otherwise
725
+ the shared component reference is used.
726
+ """
727
+ default_fmt = _query_default_format(variables) if variables is not None else None
728
+ if not default_fmt:
729
+ return _F
730
+ return {
731
+ "name": "f", "in": "query", "required": False,
732
+ "description": (
733
+ "The optional f parameter indicates the output format which the server "
734
+ "shall provide as part of the response document. Defaults to "
735
+ f"{default_fmt} for this query."
736
+ ),
737
+ "schema": {
738
+ "type": "string",
739
+ "enum": _f_enum(profile),
740
+ "default": _format_media_type(profile, default_fmt),
741
+ },
742
+ "style": "form", "explode": False,
743
+ }
744
+
745
+
746
+ def _params_with_default_f(params: list[dict], profile: "ServiceProfile | None",
747
+ variables: object | None) -> list[dict]:
748
+ """Replace the shared `_F` reference in a params list with a query-specific default."""
749
+ f_param = _f_param(profile, variables)
750
+ if f_param is _F:
751
+ return params
752
+ return [f_param if p is _F else p for p in params]
753
+
754
+
668
755
  def _collection_paths(coll: Collection, examples: dict | None = None,
669
756
  profile: ServiceProfile | None = None) -> dict:
670
757
  paths: dict = {}
671
- base = f"/collections/{coll.id}"
672
758
  tag = coll.id
673
759
  desc = getattr(coll, "description", None) or coll.id
760
+ base = f"/collections/{coll.id}"
674
761
 
675
762
  # Resolve effective POST flag: collection-level overrides profile-level default.
676
763
  # Collection.post_queries is None → inherit; True/False → explicit override.
@@ -726,10 +813,15 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
726
813
  if not coll.data_queries:
727
814
  return paths
728
815
 
729
- active = {name for name, val in coll.data_queries if val is not None}
816
+ dq_map = {name: val for name, val in coll.data_queries if val is not None}
817
+ active = set(dq_map)
818
+
819
+ def _vars(query_type: str) -> object | None:
820
+ qv = dq_map.get(query_type)
821
+ return qv.link.variables if qv is not None and qv.link else None
730
822
 
731
823
  for qt in active:
732
- params = _QUERY_PARAMS.get(qt, [])
824
+ params = _params_with_default_f(_QUERY_PARAMS.get(qt, []), profile, _vars(qt))
733
825
 
734
826
  if qt == "instances":
735
827
  paths[f"{base}/instances"] = {"get": {
@@ -761,7 +853,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
761
853
  }}
762
854
  # instance-level query sub-paths — GET + optional POST
763
855
  for sub_qt in (active - {"instances"}):
764
- sub_params = _QUERY_PARAMS.get(sub_qt, [])
856
+ sub_params = _params_with_default_f(_QUERY_PARAMS.get(sub_qt, []), profile, _vars(sub_qt))
765
857
  get_op = {
766
858
  "summary": f"query {coll.id} instance by {sub_qt}",
767
859
  "description": desc,
@@ -813,7 +905,7 @@ def _collection_paths(coll: Collection, examples: dict | None = None,
813
905
  "tags": [tag],
814
906
  "parameters": [
815
907
  {"$ref": f"{_EDR}/parameters/locationId.yaml"},
816
- _DATETIME, _PARAM_NAME, _F,
908
+ _DATETIME, _PARAM_NAME, _f_param(profile, _vars("locations")),
817
909
  ],
818
910
  "responses": cov_resp,
819
911
  }
@@ -1017,32 +1109,60 @@ def build_openapi(profile: ServiceProfile) -> dict:
1017
1109
  tags += [{"name": "jobs"}]
1018
1110
 
1019
1111
  # Build f parameter enum from profile output formats + standard formats
1020
- f_enum = ["json", "html"]
1021
- for fmt in profile.output_formats:
1022
- if fmt.media_type not in f_enum:
1023
- f_enum.append(fmt.media_type)
1112
+ f_enum = _f_enum(profile)
1024
1113
 
1025
- # Build contact from document_metadata if available
1114
+ # Build the info.contact block. A profile-level `provider` takes precedence;
1115
+ # otherwise fall back to document_metadata editors/orgs.
1026
1116
  m = profile.document_metadata
1027
1117
  contact: dict = {}
1028
- if m and m.editors:
1118
+ if profile.provider:
1119
+ p = profile.provider
1120
+ contact["name"] = p.name
1121
+ if p.url:
1122
+ contact["url"] = p.url
1123
+ if p.contact:
1124
+ c = p.contact
1125
+ if c.email:
1126
+ contact["email"] = c.email
1127
+ # OpenAPI contact only models name/url/email — carry the rest as x- extensions.
1128
+ for key in ("phone", "hours", "instructions", "address", "postalcode", "city", "country"):
1129
+ val = getattr(c, key, None)
1130
+ if val:
1131
+ contact[f"x-{key}"] = val
1132
+ if "name" not in contact and m and m.editors:
1029
1133
  contact["name"] = m.editors[0]
1030
- if m and m.submitting_orgs:
1134
+ if "x-organization" not in contact and m and m.submitting_orgs:
1031
1135
  contact["x-organization"] = m.submitting_orgs[0]
1032
1136
  # Fall back to a generic contact if nothing is specified
1033
1137
  if not contact:
1034
1138
  contact["name"] = profile.title
1035
1139
 
1140
+ # Assemble the info block, adding optional x- metadata extensions.
1141
+ info: dict = {
1142
+ "title": profile.title,
1143
+ "version": profile.version,
1144
+ "description": profile.description or f"OGC API - EDR Part 3 Service Profile: {profile.title}",
1145
+ "x-ogc-profile": profile.req_uri,
1146
+ "x-keywords": profile.keywords if profile.keywords else [],
1147
+ "contact": contact,
1148
+ }
1149
+ if profile.classification:
1150
+ info["x-classification"] = {
1151
+ k: v for k, v in (
1152
+ ("level", profile.classification.level),
1153
+ ("system", profile.classification.system),
1154
+ ) if v
1155
+ }
1156
+ if profile.metadata_date:
1157
+ info["x-metadata-date"] = profile.metadata_date
1158
+ if profile.resource_service_publish_date:
1159
+ info["x-resource-publish-date"] = profile.resource_service_publish_date
1160
+ if profile.resource_default_locale:
1161
+ info["x-default-locale"] = profile.resource_default_locale
1162
+
1036
1163
  return {
1037
1164
  "openapi": "3.1.0",
1038
- "info": {
1039
- "title": profile.title,
1040
- "version": profile.version,
1041
- "description": profile.description or f"OGC API - EDR Part 3 Service Profile: {profile.title}",
1042
- "x-ogc-profile": profile.req_uri,
1043
- "x-keywords": profile.keywords if profile.keywords else [],
1044
- "contact": contact,
1045
- },
1165
+ "info": info,
1046
1166
  # Per OGC API - EDR Part 3 REQ_publishing: this profile is implementation-independent.
1047
1167
  # The placeholder server URL "/" is required by OpenAPI validators but implementations
1048
1168
  # SHALL substitute their own server URL when deploying this profile.
@@ -1248,10 +1368,12 @@ def _individual_test_adoc(profile: ServiceProfile, test_id: str) -> str:
1248
1368
  def _build_document_adoc(profile: ServiceProfile) -> str:
1249
1369
  m = profile.document_metadata
1250
1370
  year = m.copyright_year if m else 2026
1251
- # draft-standard is the correct OGC doctype for in-progress profiles
1252
- doctype = "draft-standard"
1253
- # profile is not a valid OGC subtype; implementation is the closest for a service profile
1254
- docsubtype = "implementation" if not m or m.doc_subtype == "profile" else m.doc_subtype
1371
+ default_date = f"{year}-01-01"
1372
+ # doctype: configurable; draft-standard is the default for in-progress profiles
1373
+ doctype = (m.doc_type if m and m.doc_type else "draft-standard")
1374
+ # docsubtype: 'profile' is the natural subtype for a service profile, but when
1375
+ # doctype is draft-standard/standard OGC expects implementation/profile etc.
1376
+ docsubtype = m.doc_subtype if m else "implementation"
1255
1377
  lines = [
1256
1378
  f"= {profile.title}",
1257
1379
  f":doctype: {doctype}",
@@ -1261,10 +1383,12 @@ def _build_document_adoc(profile: ServiceProfile) -> str:
1261
1383
  ":committee: technical",
1262
1384
  f":docnumber: {m.doc_number if m else profile.name}",
1263
1385
  f":copyright-year: {year}",
1264
- f":published-date: {year}-01-01",
1265
- f":issued-date: {year}-01-01",
1266
- f":received-date: {year}-01-01",
1386
+ f":published-date: {(m.publication_date if m and m.publication_date else default_date)}",
1387
+ f":issued-date: {(m.approval_date if m and m.approval_date else default_date)}",
1388
+ f":received-date: {(m.submission_date if m and m.submission_date else default_date)}",
1267
1389
  ]
1390
+ if m and m.status:
1391
+ lines.append(f":status: {m.status}")
1268
1392
  if m and m.external_id:
1269
1393
  lines.append(f":external-id: {m.external_id}")
1270
1394
  if m and m.editors:
@@ -1303,18 +1427,91 @@ def _build_document_adoc(profile: ServiceProfile) -> str:
1303
1427
  def _build_sections(profile: ServiceProfile) -> dict[str, str]:
1304
1428
  """Return the minimal boilerplate sections required by Metanorma OGC."""
1305
1429
  m = profile.document_metadata
1306
- conf_uris = [f"http://www.opengis.net/spec/ogcapi-edr-3/1.0/conf/{profile.name}"]
1307
- submitters = m.submitting_orgs if m and m.submitting_orgs else ["Unknown"]
1430
+ conf_uris = [profile.conf_uri]
1308
1431
  req_includes = "\n".join(
1309
1432
  f"include::../requirements/core/REQ_{r.id}.adoc[]" for r in profile.requirements
1310
1433
  )
1311
1434
  ats_includes = "\n".join(
1312
1435
  f"include::../abstract_tests/core/ATS_{t.id}.adoc[]" for t in profile.abstract_tests
1313
1436
  )
1437
+
1438
+ # --- Submitters table rows (page iv) ---
1439
+ # Prefer the structured `submitters` list (each with its own affiliation).
1440
+ # Fall back to pairing editors with the first submitting org.
1441
+ if m and m.submitters:
1442
+ submitter_rows = "\n".join(
1443
+ f"| {s.name}{f' _({s.role})_' if s.role else ''} |{s.affiliation or ''}"
1444
+ for s in m.submitters
1445
+ )
1446
+ else:
1447
+ fallback_org = (m.submitting_orgs[0] if m and m.submitting_orgs else "")
1448
+ submitter_rows = "\n".join(
1449
+ f"| {editor} _(editor)_ |{fallback_org}"
1450
+ for editor in (m.editors if m and m.editors else ["Unknown"])
1451
+ )
1452
+
1453
+ # --- Notice paragraph (front matter) ---
1454
+ notice_block = ""
1455
+ if m and m.notice:
1456
+ notice_block = (
1457
+ "\n[NOTE]\n"
1458
+ "====\n"
1459
+ f"{m.notice}\n"
1460
+ "====\n"
1461
+ )
1462
+
1463
+ # --- References: base OGC refs + any profile-supplied normative references ---
1464
+ reference_lines = [
1465
+ "* [[[OGC-EDR-1,OGC 19-086r6]]], OGC API - Environmental Data Retrieval Standard",
1466
+ "* [[[OGC-EDR-3,nofetch(OGC ogcapi-edr-3)]]], OGC API - EDR Part 3: Service Profiles (draft)",
1467
+ ]
1468
+ if m and m.normative_references:
1469
+ for ref in m.normative_references:
1470
+ reference_lines.append(f"* [[[{ref.anchor},{ref.citation}]]], {ref.title}")
1471
+
1472
+ # --- Classification banner (e.g. NATO RESTRICTED) ---
1473
+ classification_banner = ""
1474
+ if profile.classification:
1475
+ cl = profile.classification
1476
+ label = cl.level + (f" ({cl.system})" if cl.system else "")
1477
+ classification_banner = f"*SECURITY CLASSIFICATION: {label}*\n\n"
1478
+
1479
+ # --- Point of contact paragraph (from provider) ---
1480
+ contact_block = ""
1481
+ if profile.provider:
1482
+ pv = profile.provider
1483
+ parts_ = [f"This service is provided by {pv.name}"]
1484
+ if pv.url:
1485
+ parts_.append(f" ({pv.url})")
1486
+ parts_.append(".")
1487
+ if pv.contact:
1488
+ c = pv.contact
1489
+ details = []
1490
+ if c.email:
1491
+ details.append(f"Email: {c.email}")
1492
+ if c.phone:
1493
+ details.append(f"Phone: {c.phone}")
1494
+ addr = ", ".join(
1495
+ x for x in (c.address, c.postalcode, c.city, c.country) if x
1496
+ )
1497
+ if addr:
1498
+ details.append(f"Address: {addr}")
1499
+ if c.hours:
1500
+ details.append(f"Hours: {c.hours}")
1501
+ if c.instructions:
1502
+ details.append(f"Contact instructions: {c.instructions}")
1503
+ if details:
1504
+ parts_.append(" " + " +\n".join(details))
1505
+ contact_block = (
1506
+ "\n[.preface]\n== Point of Contact\n\n"
1507
+ + "".join(parts_) + "\n"
1508
+ )
1509
+
1314
1510
  return {
1315
1511
  "sections/00-abstract.adoc": (
1316
1512
  "[abstract]\n== Abstract\n\n"
1317
- f"This document defines the {profile.title}, "
1513
+ + classification_banner
1514
+ + f"This document defines the {profile.title}, "
1318
1515
  "an OGC API - Environmental Data Retrieval (EDR) Part 3 Service Profile. "
1319
1516
  "It specifies normative requirements and conformance tests for server implementations "
1320
1517
  f"conforming to this profile.\n"
@@ -1325,15 +1522,14 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
1325
1522
  ".Submitters\n"
1326
1523
  "|===\n"
1327
1524
  "h|Name h|Affiliation\n\n"
1328
- + "\n".join(
1329
- f"| {editor} _(editor)_ |{submitters[0] if submitters else ''}"
1330
- for editor in (m.editors if m and m.editors else ["Unknown"])
1331
- )
1525
+ + submitter_rows
1332
1526
  + "\n|===\n"
1333
1527
  ),
1334
1528
  "sections/01-preface.adoc": (
1335
1529
  "[.preface]\n== Preface\n\n"
1336
1530
  f"This document was prepared by {', '.join(m.submitting_orgs) if m and m.submitting_orgs else 'the submitting organizations'}.\n"
1531
+ + notice_block
1532
+ + contact_block
1337
1533
  ),
1338
1534
  "sections/02-scope.adoc": (
1339
1535
  "== Scope\n\n"
@@ -1348,8 +1544,7 @@ def _build_sections(profile: ServiceProfile) -> dict[str, str]:
1348
1544
  ),
1349
1545
  "sections/04-references.adoc": (
1350
1546
  "[bibliography]\n== References\n\n"
1351
- "* [[[OGC-EDR-1,OGC 19-086r6]]], OGC API - Environmental Data Retrieval Standard\n"
1352
- "* [[[OGC-EDR-3,nofetch(OGC ogcapi-edr-3)]]], OGC API - EDR Part 3: Service Profiles (draft)\n"
1547
+ + "\n".join(reference_lines) + "\n"
1353
1548
  ),
1354
1549
  "sections/05-terms.adoc": (
1355
1550
  "== Terms, Definitions and Abbreviated Terms\n\n"
@@ -79,6 +79,31 @@ class ExtentWithNullBounds(EDRExtent):
79
79
  temporal: Optional[TemporalWithNullBounds] = None
80
80
 
81
81
 
82
+ class Classification(BaseModel):
83
+ """Security classification metadata (e.g. for DGIWG / military profiles)."""
84
+ level: str = Field(description="Classification level, e.g. 'NATO RESTRICTED (NR)'")
85
+ system: str | None = Field(default=None, description="Classification system, e.g. 'NATO'")
86
+
87
+
88
+ class ProviderContact(BaseModel):
89
+ """Point-of-contact details for the service provider (OWS/ISO-style)."""
90
+ email: str | None = None
91
+ phone: str | None = None
92
+ hours: str | None = None
93
+ instructions: str | None = None
94
+ address: str | None = None
95
+ postalcode: str | None = None
96
+ city: str | None = None
97
+ country: str | None = None
98
+
99
+
100
+ class Provider(BaseModel):
101
+ """Service provider / responsible party (OWS ServiceProvider-like)."""
102
+ name: str
103
+ url: str | None = None
104
+ contact: ProviderContact | None = None
105
+
106
+
82
107
  class Collection(EDRCollection):
83
108
  """
84
109
  EDR Collection with a null-aware temporal extent and optional parameter_names.
@@ -116,6 +141,13 @@ class Collection(EDRCollection):
116
141
  "Omit (null) to inherit the profile-level default."
117
142
  ),
118
143
  )
144
+ # Optional per-collection metadata. When set, overrides the profile-level
145
+ # value for this collection. Round-tripped into profile_config.json.
146
+ classification: Optional[Classification] = None
147
+ provider: Optional[Provider] = None
148
+ metadata_date: Optional[str] = None
149
+ resource_service_publish_date: Optional[str] = None
150
+ resource_default_locale: Optional[str] = None
119
151
 
120
152
 
121
153
  # ---------------------------------------------------------------------------
@@ -255,16 +287,112 @@ class OutputFormat(BaseModel):
255
287
  schema_ref: str | None = Field(default=None, description="URL to schema definition")
256
288
 
257
289
 
290
+ class Submitter(BaseModel):
291
+ """A single entry in the document's submitters table (page iv).
292
+
293
+ Allows each submitter to carry their own affiliation, rather than sharing
294
+ a single organisation across every editor.
295
+ """
296
+ name: str
297
+ affiliation: str | None = None
298
+ role: str | None = Field(
299
+ default=None,
300
+ description="Role annotation shown next to the name, e.g. 'editor' or 'contributor'",
301
+ )
302
+
303
+
304
+ class NormativeReference(BaseModel):
305
+ """An additional normative/bibliographic reference for the References section.
306
+
307
+ Rendered as a Metanorma bibliography entry of the form
308
+ ``* [[[<anchor>,<citation>]]], <title>``.
309
+ """
310
+ anchor: Annotated[str, Field(pattern=r"^[A-Za-z0-9_\-]+$")] = Field(
311
+ description="Cross-reference anchor (letters, digits, dash, underscore), e.g. DGIWG-250",
312
+ )
313
+ citation: str = Field(description="Short citation label, e.g. 'DGIWG 250' or 'OGC 19-086r6'")
314
+ title: str = Field(description="Full reference title")
315
+
316
+
317
+ # Metanorma OGC document stages (`:status:` / `:docstage:`). See
318
+ # https://www.metanorma.org/author/ogc/ref/document-attributes/
319
+ DocStatus = Literal[
320
+ "draft",
321
+ "work-item-draft",
322
+ "swg-draft",
323
+ "oab-review",
324
+ "public-rfc",
325
+ "tc-vote",
326
+ "approved",
327
+ "published",
328
+ "deprecated",
329
+ "rescinded",
330
+ "retired",
331
+ "legacy",
332
+ ]
333
+
334
+ # Loose ISO 8601 date (YYYY-MM-DD) check for document dates.
335
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
336
+
337
+
258
338
  class DocumentMetadata(BaseModel):
259
339
  """Metanorma/OGC document header metadata for PDF compilation."""
260
340
  doc_number: str
261
- doc_subtype: Literal["implementation", "best-practice", "engineering-report"] = "implementation"
341
+ doc_type: str = Field(
342
+ default="draft-standard",
343
+ description=(
344
+ "Metanorma OGC :doctype: value (e.g. 'draft-standard', 'standard', "
345
+ "'best-practice', 'engineering-report'). Drives the cover-page document type."
346
+ ),
347
+ )
348
+ doc_subtype: Literal["implementation", "best-practice", "engineering-report", "profile"] = "implementation"
349
+ status: DocStatus | None = Field(
350
+ default=None,
351
+ description=(
352
+ "Metanorma OGC :status:/:docstage: value (e.g. 'swg-draft', 'approved'). "
353
+ "Combined with doc_type/doc_subtype this produces the cover-page sub-title."
354
+ ),
355
+ )
262
356
  editors: list[str] = Field(default_factory=list)
263
357
  submitting_orgs: list[str] = Field(default_factory=list)
358
+ submitters: list[Submitter] = Field(
359
+ default_factory=list,
360
+ description=(
361
+ "Structured submitters table (name + affiliation + role) for page iv. "
362
+ "When provided, takes precedence over the editors/submitting_orgs fallback."
363
+ ),
364
+ )
264
365
  keywords: list[str] = Field(default_factory=list)
265
366
  copyright_year: int = Field(default=2026)
367
+ # Title-page dates. Map to Metanorma attributes:
368
+ # submission_date -> :received-date: (:submissionDate:)
369
+ # approval_date -> :issued-date: (:approvalDate:)
370
+ # publication_date -> :published-date: (:publicationDate:)
371
+ # Format: YYYY-MM-DD. When omitted, falls back to {copyright_year}-01-01.
372
+ submission_date: str | None = Field(default=None, description="Submission date (YYYY-MM-DD)")
373
+ approval_date: str | None = Field(default=None, description="Approval date (YYYY-MM-DD)")
374
+ publication_date: str | None = Field(default=None, description="Publication date (YYYY-MM-DD)")
375
+ notice: str | None = Field(
376
+ default=None,
377
+ description=(
378
+ "Custom notice text rendered in the document front matter. Note: the OGC "
379
+ "cover-page legal notice itself is generated by Metanorma from doc_type/status; "
380
+ "this field adds a notice paragraph in the preface."
381
+ ),
382
+ )
383
+ normative_references: list[NormativeReference] = Field(
384
+ default_factory=list,
385
+ description="Additional normative references appended to the References section.",
386
+ )
266
387
  external_id: str | None = None
267
388
 
389
+ @field_validator("submission_date", "approval_date", "publication_date")
390
+ @classmethod
391
+ def _valid_date(cls, v: str | None) -> str | None:
392
+ if v is not None and not _DATE_RE.match(v):
393
+ raise ValueError("date must be in YYYY-MM-DD format")
394
+ return v
395
+
268
396
 
269
397
  class PubSubServer(BaseModel):
270
398
  """A single pub/sub server endpoint."""
@@ -324,6 +452,34 @@ class ServiceProfile(BaseModel):
324
452
  ),
325
453
  )
326
454
  server_url: str | None = Field(default=None, description="Base URL for implementation (not used in profile OpenAPI per standard)")
455
+ provider: Provider | None = Field(
456
+ default=None,
457
+ description=(
458
+ "Service provider / responsible party. Surfaces in the OpenAPI info.contact "
459
+ "block (name/url/email plus x- extensions for phone, address, etc.) and as the "
460
+ "document point of contact."
461
+ ),
462
+ )
463
+ classification: Classification | None = Field(
464
+ default=None,
465
+ description=(
466
+ "Security classification of the service/profile (e.g. NATO RESTRICTED). "
467
+ "Surfaces as a banner in the generated document and as info.x-classification "
468
+ "in the OpenAPI."
469
+ ),
470
+ )
471
+ metadata_date: str | None = Field(
472
+ default=None,
473
+ description="Date the metadata was last updated (ISO 8601 string). Surfaces as info.x-metadata-date.",
474
+ )
475
+ resource_service_publish_date: str | None = Field(
476
+ default=None,
477
+ description="Date the resource/service was published (ISO 8601 string). Surfaces as info.x-resource-publish-date.",
478
+ )
479
+ resource_default_locale: str | None = Field(
480
+ default=None,
481
+ description="Default locale of the resource (e.g. 'eng'). Surfaces as info.x-default-locale.",
482
+ )
327
483
  collections: list[Collection] = Field(min_length=1)
328
484
  collection_examples: dict[str, dict] = Field(default_factory=dict)
329
485
  requirements: list[Requirement] = Field(default_factory=list)
@@ -361,6 +517,16 @@ class ServiceProfile(BaseModel):
361
517
  default=None,
362
518
  description="Regex pattern for valid collection IDs"
363
519
  )
520
+ spec_uri_base: str = Field(
521
+ default="http://www.opengis.net/spec/ogcapi-edr-3/1.0",
522
+ description=(
523
+ "Base URI for the requirements class and conformance class identifiers. "
524
+ "Defaults to the OGC API - EDR Part 3 namespace. Override this to publish "
525
+ "under a different SDO namespace (e.g. a DGIWG location). Used to derive "
526
+ "req_uri and conf_uri, which appear in the OpenAPI x-ogc-profile link, the "
527
+ "requirements/conformance AsciiDoc identifiers, and the conformance section."
528
+ ),
529
+ )
364
530
  parameter_name_pattern: str | None = Field(
365
531
  default=None,
366
532
  description="Regex pattern that all parameter_names keys must match"
@@ -376,14 +542,14 @@ class ServiceProfile(BaseModel):
376
542
  ),
377
543
  )
378
544
 
379
- # OGC identifiers derived from name — not user-supplied
545
+ # OGC identifiers derived from name and spec_uri_base — not user-supplied directly
380
546
  @property
381
547
  def req_uri(self) -> str:
382
- return f"http://www.opengis.net/spec/ogcapi-edr-3/1.0/req/{self.name}"
548
+ return f"{self.spec_uri_base.rstrip('/')}/req/{self.name}"
383
549
 
384
550
  @property
385
551
  def conf_uri(self) -> str:
386
- return f"http://www.opengis.net/spec/ogcapi-edr-3/1.0/conf/{self.name}"
552
+ return f"{self.spec_uri_base.rstrip('/')}/conf/{self.name}"
387
553
 
388
554
  @model_validator(mode="after")
389
555
  def tests_reference_valid_requirements(self) -> ServiceProfile:
@@ -503,17 +669,18 @@ class ServiceProfile(BaseModel):
503
669
  for crs_val in coll.crs:
504
670
  _check(f"Collection '{coll.id}' crs[]", crs_val, er.supported_crs)
505
671
 
506
- # --- crs_details in data_queries → supported_crs ---
672
+ # --- crs_details / crs shorthand in data_queries → supported_crs ---
507
673
  if coll.data_queries and er.supported_crs:
508
674
  for qt_name, qt_val in coll.data_queries:
509
675
  if qt_val is None:
510
676
  continue
511
- crs_details = (
512
- qt_val.link.variables.model_extra.get("crs_details", [])
677
+ extra = (
678
+ qt_val.link.variables.model_extra
513
679
  if hasattr(qt_val.link.variables, "model_extra")
514
- else []
515
- )
516
- for entry in (crs_details or []):
680
+ else {}
681
+ ) or {}
682
+ crs_details = extra.get("crs_details", []) or []
683
+ for entry in crs_details:
517
684
  dq_crs = entry.get("crs") if isinstance(entry, dict) else None
518
685
  if dq_crs:
519
686
  _check(
@@ -521,6 +688,41 @@ class ServiceProfile(BaseModel):
521
688
  dq_crs,
522
689
  er.supported_crs,
523
690
  )
691
+ # `crs` is an accepted shorthand: a plain list of CRS URIs at
692
+ # the query level, equivalent to crs_details: [{crs: ...}].
693
+ for dq_crs in (extra.get("crs", []) or []):
694
+ if isinstance(dq_crs, str):
695
+ _check(
696
+ f"Collection '{coll.id}' data_queries.{qt_name}.crs",
697
+ dq_crs,
698
+ er.supported_crs,
699
+ )
700
+ return self
701
+
702
+ @model_validator(mode="after")
703
+ def validate_default_output_format(self) -> ServiceProfile:
704
+ """Ensure a query's default_output_format is one of its output_formats."""
705
+ for coll in self.collections:
706
+ if not coll.data_queries:
707
+ continue
708
+ for qt_name, qt_val in coll.data_queries:
709
+ if qt_val is None:
710
+ continue
711
+ extra = (
712
+ qt_val.link.variables.model_extra
713
+ if hasattr(qt_val.link.variables, "model_extra")
714
+ else {}
715
+ ) or {}
716
+ variables = qt_val.link.variables
717
+ default = getattr(variables, "default_output_format", None) or extra.get("default_output_format")
718
+ if default is None:
719
+ continue
720
+ formats = getattr(variables, "output_formats", None) or extra.get("output_formats") or []
721
+ if formats and default not in formats:
722
+ raise ValueError(
723
+ f"Collection '{coll.id}' data_queries.{qt_name}: "
724
+ f"default_output_format '{default}' is not in output_formats {formats}"
725
+ )
524
726
  return self
525
727
 
526
728
  @model_validator(mode="after")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oapi-profile-builder
3
- Version: 3.0.0
3
+ Version: 3.1.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
@@ -290,12 +290,18 @@ abstract_tests: []
290
290
  | `description` | string | no | Human-readable description of the service profile. Surfaces in the OpenAPI `info.description` and the landing page response schema |
291
291
  | `keywords` | list | no | Service-level keywords (e.g. query types, parameter names, domain terms). Surfaces in `info.x-keywords` and the landing page response schema. Distinct from `document_metadata.keywords`, which are for the OGC PDF header |
292
292
  | `server_url` | string | no | Documentation only — not written to the profile OpenAPI |
293
+ | `provider` | object | no | Service provider / responsible party. Surfaces in OpenAPI `info.contact` and the document Point of Contact (see below) |
294
+ | `classification` | object | no | Security classification (`level` + `system`). Surfaces as a document banner and `info.x-classification` |
295
+ | `metadata_date` | string | no | Metadata update date (ISO 8601). Surfaces as `info.x-metadata-date` |
296
+ | `resource_service_publish_date` | string | no | Resource/service publication date (ISO 8601). Surfaces as `info.x-resource-publish-date` |
297
+ | `resource_default_locale` | string | no | Default resource locale (e.g. `eng`). Surfaces as `info.x-default-locale` |
293
298
  | `allow_post_queries` | bool | no | When `true`, generates `POST` alongside `GET` for all EDR data query endpoints. Defaults to `false` |
294
299
  | `collections` | list | yes | One or more EDR collections (see below) |
295
300
  | `required_conformance_classes` | list | no | Conformance classes implementations must declare. Defaults to EDR Core |
296
301
  | `extent_requirements` | object | no | Profile-level CRS/TRS/VRS constraints (see below) |
297
302
  | `output_formats` | list | no | Format name → media type + schema ref mappings |
298
303
  | `collection_id_pattern` | string | no | Regex all collection IDs must match |
304
+ | `spec_uri_base` | string | no | Base URI for the requirements/conformance class identifiers. Defaults to the OGC EDR Part 3 namespace; override to publish under another SDO namespace (e.g. DGIWG) |
299
305
  | `parameter_name_pattern` | string | no | Regex all `parameter_names` keys must match |
300
306
  | `parameter_schema` | object | no | JSON Schema for parameter objects — replaces the default schema in the generated OpenAPI (see below) |
301
307
  | `processes` | list | no | OGC API Processes to expose in the OpenAPI |
@@ -303,7 +309,7 @@ abstract_tests: []
303
309
  | `abstract_tests` | list | no | Conformance tests — each must reference a valid requirement `id` |
304
310
  | `pubsub` | object | no | OGC API - EDR Part 2 PubSub config — generates `asyncapi.yaml` |
305
311
  | `collection_examples` | object | no | `{collectionId: {instanceId: "..."}}` — used by `validate-server` |
306
- | `document_metadata` | object | no | Metanorma PDF header (doc number, editors, orgs, keywords) |
312
+ | `document_metadata` | object | no | Metanorma PDF header — doc number/type/subtype, status, editors, submitters, dates, notice, normative references (see below) |
307
313
 
308
314
  ---
309
315
 
@@ -360,6 +366,22 @@ data_queries:
360
366
  crs_details:
361
367
  - crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
362
368
  - crs: "http://www.opengis.net/def/crs/EPSG/0/4326"
369
+ cube:
370
+ link:
371
+ href: https://example.com/collections/obs/cube
372
+ rel: data
373
+ variables:
374
+ query_type: cube
375
+ output_formats: [CoverageJSON, GeoTIFF]
376
+ # default_output_format: response format used when the `f` query
377
+ # parameter is omitted. Must be one of output_formats. Sets the
378
+ # `default` on the generated OpenAPI `f` parameter for this query.
379
+ default_output_format: GeoTIFF
380
+ # crs: shorthand for crs_details — a plain list of CRS URIs.
381
+ # Validated against extent_requirements.supported_crs.
382
+ crs:
383
+ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
384
+ - "http://www.opengis.net/def/crs/EPSG/0/4326"
363
385
  ```
364
386
 
365
387
  #### `parameter_names` example
@@ -536,16 +558,39 @@ pubsub:
536
558
 
537
559
  ### `document_metadata`
538
560
 
539
- Required only when compiling a PDF with `--pdf`.
561
+ Required only when compiling a PDF with `--pdf`. Fields map to Metanorma OGC document attributes.
540
562
 
541
563
  ```yaml
542
564
  document_metadata:
543
565
  doc_number: "25-myprofile"
544
- doc_subtype: implementation # implementation | best-practice | engineering-report
566
+ doc_type: draft-standard # Metanorma :doctype: (draft-standard | standard | best-practice | engineering-report | ...)
567
+ doc_subtype: implementation # implementation | best-practice | engineering-report | profile
568
+ status: approved # Metanorma :status:/:docstage: (swg-draft | public-rfc | approved | ...)
545
569
  editors:
546
570
  - Jane Smith
571
+ # submitters: structured table for page iv — each row carries its own affiliation.
572
+ # When provided, takes precedence over the editors/submitting_orgs fallback.
573
+ submitters:
574
+ - name: Jane Smith
575
+ affiliation: My Organization
576
+ role: editor
577
+ - name: Standards Working Group
578
+ affiliation: My SDO
547
579
  submitting_orgs:
548
580
  - My Organization
581
+ # Title-page dates (YYYY-MM-DD). Omitted dates fall back to {copyright_year}-01-01.
582
+ submission_date: "2026-05-01" # → :received-date:
583
+ approval_date: "2026-06-15" # → :issued-date:
584
+ publication_date: "2026-07-01" # → :published-date:
585
+ # Custom notice paragraph rendered in the front matter. Note: the OGC cover-page
586
+ # legal notice itself is generated by Metanorma from doc_type/status.
587
+ notice: >
588
+ This is a DRAFT standard and is subject to change.
589
+ # Additional normative references appended to the References section.
590
+ normative_references:
591
+ - anchor: DGIWG-250
592
+ citation: DGIWG 250
593
+ title: DGIWG GeoTIFF Profile
549
594
  keywords:
550
595
  - ogcdoc
551
596
  - OGC API
@@ -554,6 +599,64 @@ document_metadata:
554
599
  external_id: http://www.opengis.net/doc/dp/my-profile/1.0
555
600
  ```
556
601
 
602
+ The cover-page sub-title (e.g. "Draft Standard — Implementation — Approved") is produced by Metanorma from the `doc_type`, `doc_subtype`, and `status` combination.
603
+
604
+ ---
605
+
606
+ ### `provider`, `classification`, and provenance metadata
607
+
608
+ Optional service-level metadata. Each may also be set per-collection (the collection value overrides the profile root for that collection and is round-tripped into `profile_config.json`).
609
+
610
+ ```yaml
611
+ provider:
612
+ name: Austrian Military
613
+ url: "https://www.bmlv.gv.at/english/forces/organisation.shtml"
614
+ contact:
615
+ email: presse@bmlvs.gv.at
616
+ phone: "+43 ..."
617
+ hours: Monday to Friday 08:00 - 17:00
618
+ instructions: During hours of Service
619
+ address: "Ministry of Defence, Rossauer Lände 1"
620
+ postalcode: A-1090
621
+ city: Vienna
622
+ country: Austria
623
+
624
+ classification:
625
+ level: "NATO RESTRICTED (NR)"
626
+ system: NATO
627
+
628
+ metadata_date: "2026-06-25T07:31Z"
629
+ resource_service_publish_date: "2026-06-24T14:10Z"
630
+ resource_default_locale: eng
631
+ ```
632
+
633
+ Where these surface:
634
+
635
+ - `provider` → OpenAPI `info.contact` (`name`/`url`/`email`; the remaining contact fields become `x-` extensions) and a **Point of Contact** section in the generated document.
636
+ - `classification` → a **security classification banner** in the document Abstract and `info.x-classification` in the OpenAPI.
637
+ - `metadata_date` / `resource_service_publish_date` / `resource_default_locale` → `info.x-metadata-date` / `info.x-resource-publish-date` / `info.x-default-locale`.
638
+
639
+ > The profile OpenAPI is implementation-independent (per EDR Part 3 REQ_publishing), so these describe the profile/data provenance rather than a specific deployment endpoint.
640
+
641
+ ---
642
+
643
+ ### `spec_uri_base`
644
+
645
+ By default, the requirements class and conformance class identifiers are published under the OGC API - EDR Part 3 namespace:
646
+
647
+ ```
648
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/req/<name>
649
+ http://www.opengis.net/spec/ogcapi-edr-3/1.0/conf/<name>
650
+ ```
651
+
652
+ Set `spec_uri_base` to publish under a different SDO namespace. This updates the OpenAPI `x-ogc-profile` link, the requirements/conformance AsciiDoc identifiers, and the conformance section:
653
+
654
+ ```yaml
655
+ spec_uri_base: "https://schemas.dgiwg.org/edr/1.0"
656
+ # → https://schemas.dgiwg.org/edr/1.0/req/<name>
657
+ # → https://schemas.dgiwg.org/edr/1.0/conf/<name>
658
+ ```
659
+
557
660
  ---
558
661
 
559
662
  ## Validation Rules
@@ -564,10 +667,12 @@ The tool enforces these rules at build time. Violations produce clear error mess
564
667
  |---|---|
565
668
  | `name` format | Must match `^[a-z0-9_]+$` |
566
669
  | No duplicate collection IDs | Across the whole profile |
567
- | `extent_requirements` requires CRS spec | Either `allowed_crs` or `crs_pattern` must be set |
568
- | Collection CRS validated | Each `extent.spatial.crs` checked against `allowed_crs`/`crs_pattern` |
569
- | Collection TRS validated | Each `extent.temporal.trs` checked against `allowed_trs`/`trs_pattern` |
570
- | `crs_details` validated | Each `data_queries.*.variables.crs_details[].crs` checked against CRS constraints |
670
+ | `extent_requirements` requires CRS spec | At least one of `extent_crs` or `supported_crs` must be set (each uses `allowed` or `pattern`) |
671
+ | Collection CRS validated | Each `extent.spatial.crs` checked against `extent_crs`; `crs[]` and `crs_details` checked against `supported_crs` |
672
+ | Collection TRS validated | Each `extent.temporal.trs` checked against `extent_trs` |
673
+ | `crs_details` / `crs` validated | Each `data_queries.*.variables.crs_details[].crs` and `crs[]` checked against `supported_crs` |
674
+ | `default_output_format` validated | Each `data_queries.*.variables.default_output_format` must be one of that query's `output_formats` |
675
+ | Document dates format | `submission_date` / `approval_date` / `publication_date` must be `YYYY-MM-DD` |
571
676
  | Parameters need `unit` + `observedProperty` | Required by OGC API - EDR Part 3 |
572
677
  | `parameter_name_pattern` enforced | All `parameter_names` keys must match if set |
573
678
  | `collection_id_pattern` enforced | All collection IDs must match if set |