This repository provides a reusable GitHub Action that fetches metadata from Geonorge and feature catalogue information from an OGC API - Features endpoint to assemble a complete product specification package. The action downloads psdata JSON, builds feature catalogue caches (JSON, Markdown, PlantUML) and renders a Markdown specification using the bundled Handlebars-style template or a custom template you supply.
jobs:
produktspesifikasjon:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: prepare
uses: arkitektum/ps.editor.actions@main
with:
metadata-id: 12345678-abcd-1234-abcd-1234567890ab
ogc-feature-api: https://example.com/collections
output-directory: produktspesifikasjon
product-slug: mitt-produkt
updated: 2025-01-01
- name: Render UML to PNG
if: steps.prepare.outputs.feature-catalogue-uml != ''
run: |
sudo apt-get update
sudo apt-get install -y plantuml graphviz
set -euo pipefail
while IFS= read -r file; do
output_dir="$(dirname "$file")"
plantuml -tpng -output "$PWD/$output_dir" "$file"
done < <(git ls-files '*.puml')
- name: Derive PNG path
if: steps.prepare.outputs.feature-catalogue-uml != ''
id: feature-png
run: |
png="${{ steps.prepare.outputs.feature-catalogue-uml }}"
png="${png%.puml}.png"
echo "path=$png" >> "$GITHUB_OUTPUT"
- id: assemble
uses: arkitektum/ps.editor.actions/assemble@main
with:
psdata-path: ${{ steps.prepare.outputs.psdata-path }}
output-path: ${{ steps.prepare.outputs.spec-markdown }}
feature-catalogue-markdown: ${{ steps.prepare.outputs.feature-catalogue-markdown }}
feature-catalogue-uml: ${{ steps.prepare.outputs.feature-catalogue-uml }}
feature-catalogue-png: ${{ steps.feature-png.outputs.path }}
updated: 2025-01-01The first action fetches and prepares every artefact. The optional PlantUML step converts the diagram to PNG before the second action stitches everything into the final Markdown document.
Inputs:
metadata-id(required): Geonorge metadata UUID used to fetch psdata content.ogc-feature-api: Fully qualified URL to an OGC API - Features/collectionsendpoint. Optional; omit when you only want psdata or when usingxmi-modelinstead.feature-type-filter: Optional list of feature type names to include (case-insensitive exact match). Provide multiple values to keep only selected feature types (applies to both OGC API and XMI).scopes: Optional YAML/JSON list (or file path) describing multiple scopes. When supplied, each scope generates its ownobjektkatalog.mdunder a scope-named folder, and the mainindex.mdlinks to each scope catalogue while keeping the main data model diagram.output-directory(defaultproduktspesifikasjon): Directory that will contain the generated artefacts.product-slug: Overrides the auto-generated folder name (derived from the psdata title).template-path: Path to a Handlebars-style template if you want to replacedata/template/ps.md.hbs.updated: Explicit value for theupdatedfield in the rendered Markdown front matter (propagated to the assemble step).xmi-model: Optional path or URL to a SOSI UML XMI feature catalogue. When supplied the OGC API input is ignored.xmi-username/xmi-password(defaultsosi/sosi): Credentials used to download the XMI catalogue.postgis-output(defaultfalse): Also write a PostGIS DDL script of the data model — see PostGIS schema.postgis-schema: Database schema the script creates the tables in. Omit to use the search path (normallypublic).pygeoapi-postgis-output(defaultfalse): Also write a pygeoapi configuration that serves the PostGIS tables with the PostgreSQL provider — see pygeoapi configuration for PostGIS.pygeoapi-gpkg-output(defaultfalse): Also write a pygeoapi configuration that serves the GeoPackage tables with the SQLiteGPKG provider — see pygeoapi configuration for GeoPackage.
Outputs:
spec-directory: Absolute path to the directory containing all generated files.psdata-path: Path to the psdata JSON file.feature-catalogue-json: Path to the collected feature catalogue JSON cache.feature-catalogue-markdown: Path to the feature catalogue Markdown table (blank if no entries were found).feature-catalogue-uml: Path to the feature catalogue PlantUML diagram (blank if no entries were found).xmi-feature-catalogue-json: Path to the XMI feature catalogue JSON cache when generated.xmi-feature-catalogue-markdown: Path to the XMI feature catalogue Markdown table when generated.xmi-feature-catalogue-uml: Path to the XMI feature catalogue PlantUML diagram when generated.feature-catalogue-postgis: Path to the PostGIS DDL script (<slug>.postgis.sql) whenpostgis-outputis set.feature-catalogue-pygeoapi-postgis: Path to the PostGIS pygeoapi configuration (<slug>.pygeoapi-postgis.yml) whenpygeoapi-postgis-outputis set.feature-catalogue-pygeoapi-gpkg: Path to the GeoPackage pygeoapi configuration (<slug>.pygeoapi-gpkg.yml) whenpygeoapi-gpkg-outputis set.spec-markdown: Reserved path for the final product specification Markdown (always<spec-directory>/index.md). The file is created by the assemble action.
Inputs:
psdata-path(required): Path to the psdata JSON file from the prepare step.output-path(required): Target path for the rendered Markdown specification.template-path: Optional override for the Handlebars-style template.feature-catalogue-markdown: Optional path to the feature catalogue Markdown table.feature-catalogue-uml: Optional path to the feature catalogue PlantUML source (embedded when no PNG is provided).feature-catalogue-png: Optional path to the rendered PlantUML PNG diagram. When missing or unavailable, the PlantUML source is embedded instead.xmi-feature-catalogue-markdown: Optional path to the XMI feature catalogue Markdown table.xmi-feature-catalogue-uml: Optional path to the XMI feature catalogue PlantUML source.xmi-feature-catalogue-png: Optional path to the rendered XMI PlantUML PNG diagram.updated: Optional override for theupdatedmetadata field.
Empty headings are removed automatically when their sections end up blank after templating. If you want to keep the headings in place when running the script directly, pass --keep-empty-headings.
Outputs:
spec-markdown: Path to the assembled product specification Markdown document (always<spec-directory>/index.md).
This optional action wraps the static-site build and artefact upload steps before actions/deploy-pages. Example:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: arkitektum/ps.editor.actions/publish@main
with:
checkout: false # already checked out
upload-path: site
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
permissions:
pages: write
id-token: write
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4Inputs:
checkout(defaultfalse): Set totrueif the action should fetch the repository.python-version(default3.11): Passed toactions/setup-python.requirements: Additional requirements file to install; leave blank (default) to skip. The action installsmarkdownandPyYAMLautomatically.extra-packages: Additional pip packages to install.upload-path(defaultsite): Directory uploaded viaactions/upload-pages-artifact. The action invokesbuild_github_pages.pyfrom this repository with<source> --output <upload-path>.working-directory(default.): Directory for installation and build commands.pythonpath: Exported asPYTHONPATHwhile running the build command.source(defaultproduktspesifikasjon): Root directory that contains the generated Markdown specifications.artifact-name(defaultgithub-pages): Name of the uploaded artefact.
Turns a feature catalogue into a GML application schema (XSD) and a JSON Schema by way of ShapeChange. The action runs the two lightweight Python halves; ShapeChange itself is a 112 MB Java distribution and is executed by the calling workflow, exactly the way PlantUML is. See Running ShapeChange for the complete snippet.
Inputs:
mode(defaultgenerate):generatewrites the SCXML model and the ShapeChange configuration;checkreads the log ShapeChange produced, fails the job on errors and uploads the artefact.output-directory(defaultshapechange): Directory holding the model, configuration, log and generated schemas.feature-catalogue-json: Path to a feature catalogue JSON file, e.g. the prepare action'sfeature-catalogue-jsonoutput. Works for every source the prepare action supports (OGC API, GeoPackage, XMI).xmi-model: Path or URL to a SOSI UML XMI feature catalogue, read directly instead of a JSON file.xmi-username/xmi-password(defaultsosi/sosi): Credentials used to download the XMI catalogue.target-namespace(required ingeneratemode): Target namespace of the application schema. None of the feature catalogue sources carry one, and ShapeChange selects application schemas by exactly this value.xmlns-prefix(defaultapp): Namespace prefix of the application schema.schema-version(default1.0): Version of the application schema.schema-name: Name of the application schema package. Defaults to the input file stem.xsd-document: File name of the generated XML Schema document. Defaults to<SchemaName>.xsd.targets(defaultxsd,json): Which ShapeChange targets to enable.xsd-encoding-rule(defaultsosi): ShapeChange XML Schema encoding rule.sosiis Kartverket's SOSI profile, written into the generated configuration — see The SOSI profile. Built-in alternatives areiso19136_2007,gml33,iso19139_2007andogcSweCommon2.json-schema-version(default2019-09): One of2020-12,2019-09,draft-07,OpenApi30.json-base-uri: Base URI used when constructing$idvalues in the JSON Schema output.json-encoding-rule(defaultsosiJson):sosiJsonis written into the generated configuration; the built-in alternatives aredefaultGeoJsonanddefaultPlainJson.referenced-schemas: YAML/JSON list (or a file path) of application schemas this model references but does not define, so ShapeChange imports the borrowed types instead of copying them — see Schemas the model borrows from.represent-tagged-values(defaultSOSI_navn,SOSI_verdi,NVDB_ID): Tagged values emitted assc:taggedValueappinfo in the XSD. Needs an encoding rule withrule-xsd-all-tagged-values, whichsosihas.codelist-as-dictionary(defaultmodel):model,trueorfalse. Decides how code lists are encoded — see Code lists.entity-type-name(default@type): Name of the entity type member in the JSON Schema output.xml-schema-target-class/json-schema-target-class: Override the ShapeChange target classes. The Java package names changed in ShapeChange 4.0.0; the defaults target 4.x.bundled-includes(defaultfalse): Reference the standard rules and map entries bundled with the ShapeChange distribution instead of the copies onshapechange.net. Requires running the jar from the distribution root.upload-artifact(defaulttrue) /artifact-name(defaultshapechange-schemas): Artefact upload incheckmode.python-version(default3.11): Passed toactions/setup-python.
Outputs:
output-directory: Directory holding the model, configuration, log and schemas.scxml-model: Path to the generated ShapeChange SCXML model.shapechange-config: Path to the generated ShapeChange configuration document — pass this tojava -jar ShapeChange.jar -c.shapechange-log: Path to the XML log ShapeChange writes.xsd-directory/json-schema-directory: Directories that receive the generated schemas.
PNG diagrams are recommended for readability, but the assemble action now falls back to embedding the PlantUML source when no PNG is supplied. Use the workflow snippet above—or any other conversion job—to generate PNGs when you want rendered graphics.
ShapeChange converts the data model into a GML application schema (XSD) and a JSON Schema. Like PlantUML, it is a heavyweight external tool, so the calling workflow runs it:
- id: schema-model
uses: arkitektum/ps.editor.actions/shapechange@main
with:
feature-catalogue-json: ${{ steps.prepare.outputs.feature-catalogue-json }}
target-namespace: https://skjema.geonorge.no/SOSI/produktspesifikasjon/DyrkbarJord/1.0
xmlns-prefix: dyrkbarjord
schema-name: DyrkbarJord
json-base-uri: https://skjema.geonorge.no/SOSI/produktspesifikasjon/DyrkbarJord
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Cache ShapeChange
id: shapechange-cache
uses: actions/cache@v4
with:
path: ~/shapechange
key: shapechange-4.0.0
- name: Download ShapeChange
if: steps.shapechange-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
mkdir -p ~/shapechange
curl -sSL -o /tmp/shapechange.zip \
https://github.com/ShapeChange/ShapeChange/releases/download/4.0.0/ShapeChange-4.0.0.zip
unzip -q /tmp/shapechange.zip -d ~/shapechange
- name: Run ShapeChange
working-directory: ~/shapechange
run: |
set -euo pipefail
java -Dfile.encoding=UTF-8 -jar ShapeChange-4.0.0.jar \
-c "${{ steps.schema-model.outputs.shapechange-config }}"
- id: schemas
uses: arkitektum/ps.editor.actions/shapechange@main
with:
mode: check
output-directory: ${{ steps.schema-model.outputs.output-directory }}Things worth knowing about this pipeline:
- ShapeChange is only distributed as a ZIP. There is no fat jar and no Maven Central artefact, and
ShapeChange-4.0.0.jarrelies on a siblinglib/directory, so the whole archive has to be unpacked. It requires Java 21. - Always pass
-c. Without a configuration file ShapeChange opens a Swing dialog, which hangs a CI job. working-directoryis the distribution root because ShapeChange resolves relative paths against the working directory. Everything this action writes into the configuration is an absolute path, so only the bundledxi:includereferences depend on it.- The
checkstep is not optional. ShapeChange returns a non-zero exit code only for a fatal abort; ordinary model and target errors are written to the log while the process still exits 0.mode: checkparses that log, turns messages into GitHub annotations and fails the job when errors were reported.
ShapeChange cannot read the SOSI XMI files directly: its XMI10 reader requires XMI 1.0 with a DOCTYPE, while the exports on sosi.geonorge.no are XMI 1.1. The action therefore reads every source through this repository's own loaders and writes a ShapeChange SCXML model, which is the recommended interchange format. The same path serves OGC API, GeoPackage and XMI sources, so feature-catalogue-json is the input to reach for; xmi-model is a shortcut when there is no prepare step.
Because the JSON structure is flat, the model ShapeChange sees is reconstructed:
- top-level entries become
<<featureType>>classes, nested attribute groups become<<dataType>>classes; - value domains become
<<codeList>>or<<enumeration>>according to the source model's stereotype, falling back on the presence of an externalcodeListURI for sources that carry no stereotypes; - geometry is normalised to the ISO 19107
GM_*types, and other types to ISO 19103 names such asCharacterStringandInteger; SOSI_navn,SOSI_verdiandNVDB_IDtagged values are carried through from XMI sources;targetNamespace,xmlns,version,xsdDocumentandjsonDocumentare synthesised from the action inputs, since no source carries them.
Constraints (OCL) and classes that are not feature types do not survive the trip through the JSON structure. GeoPackage sources are the thinnest input of all — no relationships, no packages, no code lists — so their schema is a flat rendering of the tables.
A SOSI model is rarely self-contained. Reguleringsplan-2024.xml defines 35 feature types but references 55 more classes that live elsewhere — Arealplan and Identifikasjon in Planregister, Posisjonskvalitet and Målemetode in the general SOSI packages. Enterprise Architect records these as <EAStub name="Arealplan" UMLType="Class"/>, carrying a name and nothing else: no namespace, no file path.
Left alone, the borrowed types are copied into your schema. The XSD looks fine and ShapeChange reports no warnings, but Identifikasjon ends up as app:IdentifikasjonPropertyType in your namespace instead of being imported from the schema that owns it. For the Reguleringsplan model that affects 43 types.
referenced-schemas tells ShapeChange who owns what:
- name: Planregister
xmi-model: https://sosi.geonorge.no/svn/SOSI/.../Arealplan%205.0/Planregister.xml
target-namespace: https://skjema.geonorge.no/SOSI/produktspesifikasjon/Planregister/20260101
xmlns-prefix: pr
version: "20260101"
location: https://skjema.geonorge.no/SOSI/produktspesifikasjon/Planregister/20260101/Planregister.xsdThe referenced model is read only to learn which type names it defines. Those types are then placed in that schema's package, and the result is a proper import:
<import namespace="https://skjema.geonorge.no/SOSI/produktspesifikasjon/Planregister/20260101"
schemaLocation="https://.../Planregister.xsd"/>
...
<element name="identifikasjon" type="pr:IdentifikasjonPropertyType"/>Only your own schema is generated — the referenced ones are imported, not rebuilt, because appSchemaNameRegex still pins the run to the main schema.
Things worth knowing:
locationis what suppliesschemaLocation. ShapeChange derives the import itself from the other package's target namespace, but it has no idea where that schema is published. Omitlocationand you get an import without a location. One entry per schema is enough — not one per type.- The declaration cannot be derived from the model. An EA stub has only a name, so the namespace, prefix and published location have to come from you. The file name is not a reliable guide either:
Reguleringsplan-2024.xmlcontains a package calledReguleringsplan-2026-Utkast. - A schema whose types are never used is skipped, so an over-broad list costs nothing but a download.
- Types you do not declare stay local. Declaring Planregister but not the general SOSI packages leaves
Posisjonskvalitetin your namespace. Add another entry to move it too.
Stock ShapeChange produces a generic ISO 19136 schema. The application schemas published on skjema.geonorge.no look different, and kartverket/ShapeChange-Add-In shows why: Kartverket runs ShapeChange with a SOSI-specific encoding rule and an extra map-entry file. That add-in is a Windows-only Enterprise Architect wrapper, so none of it runs in CI — but the two configuration fragments are plain ShapeChange and are reused here.
xsd-encoding-rule: sosi (the default) writes Kartverket's rule into the generated configuration:
<EncodingRule name="sosi" extends="iso19136_2007">
<rule name="rule-xsd-prop-length-size-pattern"/>
<rule name="rule-xsd-all-tagged-values"/>
<rule name="rule-xsd-all-notEncoded"/>
<rule name="rule-xsd-pkg-schematron"/>
<rule name="rule-xsd-prop-nillable"/>
<rule name="rule-xsd-prop-targetCodeListURI"/>
</EncodingRule>Every individual rule is stock ShapeChange — only the grouping is Kartverket's — so no patched ShapeChange is needed. Two of them do the visible work:
rule-xsd-all-tagged-values, together withrepresent-tagged-values, emits<sc:taggedValue tag="SOSI_navn">FLATE</sc:taggedValue>appinfo, exactly as in the published Geonorge schemas;rule-xsd-prop-targetCodeListURIemits<sc:targetCodeListURI>carrying each code list's registry URI, so the link toregister.geonorge.nosurvives whichever encoding the code list gets.
The profile is purely additive: running the same model through iso19136_2007 and sosi yields structurally identical schemas, and the sosi output only gains the sc: import and the appinfo blocks.
Alongside it, shapechange/data/StandardMapEntries_sosi.xml maps the Norwegian SOSI type names — Navn, Høyde, Dybde, Organisasjonsnummer, URI, and the geometry types Punkt/Kurve/Flate/Sverm. It is vendored rather than fetched from sosi.geonorge.no, because the upstream copy is served over plain HTTP from a version-pinned path. One deliberate fix: upstream maps Høyde twice, to double and to string; only the double mapping is kept.
Set xsd-encoding-rule: iso19136_2007 to opt out and get plain ShapeChange behaviour.
ShapeChange encodes a <<codeList>> in one of two shapes, chosen by its asDictionary tagged value:
asDictionary |
XSD |
|---|---|
true |
<element name="kommunenummer" type="gml:CodeType"/> |
false |
<union memberTypes="app:KommunenummerEnumerationType app:KommunenummerOtherType"/> with pattern "other: \w{2,}" |
The published Geonorge schemas use the union form throughout. codelist-as-dictionary decides which you get: model (default) keeps whatever the source model says, while true/false overrides it. The registry URI is preserved either way through rule-xsd-prop-targetCodeListURI, so the choice is about instance-document form, not about losing information.
An <<enumeration>> is different again — a closed restriction with no escape hatch. That is why the source stereotype is carried through rather than guessed.
Both built-in JSON encoding rules include rule-json-cls-name-as-anchor, which encodes every class name as a JSON Schema $anchor. JSON Schema requires anchors to match ^[A-Za-z][-A-Za-z0-9.:_]*$, so a Norwegian name such as Målemetode produces a document that fails strict metaschema validation.
ShapeChange encoding rules can only add rules, never remove one, so the fix is a rule that does not extend a built-in. That is what json-encoding-rule: sosiJson (the default) is: Kartverket's three-rule defaultJson plus what is needed for geometry, identity and documentation, and without the anchor rule. The result validates cleanly as Draft 2019-09 while keeping GeoJSON geometry ("geometry": {"$ref": "https://geojson.org/schema/MultiPolygon.json"}).
Set json-encoding-rule: defaultGeoJson for stock behaviour, at the cost of the $anchor problem returning.
Geonorge date-stamps its application schemas, using the same stamp as both the last path segment of the namespace and the schema version:
target-namespace: https://skjema.geonorge.no/SOSI/produktspesifikasjon/Dyrkbarjord/20250530
schema-version: "20250530"
xmlns-prefix: appapp is the prefix Geonorge uses, and is already the default.
With postgis-output: true (or --postgis-output locally) the prepare action also writes <slug>.postgis.sql: a script that creates an empty PostGIS schema for the data model. It is plain SQL — nothing connects to a database — and runs in a single transaction:
psql -v ON_ERROR_STOP=1 -d mydb -f produktspesifikasjon/<slug>/<slug>.postgis.sqlThe layout follows the same conventions as the GeoPackage output, so one ldproxy provider structure fits both:
- every table has an integer identity primary key
objid, plus anobjtypecolumn holding the class name; - abstract classes get no table; their attributes are materialised in every concrete subtype;
- single-valued data types are flattened into columns (
identifikasjon_lokalid); - an attribute that may repeat gets a child table
<table>_<attribute>whose<table>_fkcolumn references the owner withON DELETE CASCADE.
Where PostGIS can say more than a GeoPackage, it does:
- Several geometries stay in one table.
Bygningwith a surface and a point keeps both as geometry columns; there is no split intoBygning_flate/Bygning_punkt. Each geometry column gets a GIST index. - Code lists become lookup tables
(identifier, description)filled with the code values and referenced by a foreign key. External code lists are fetched from the Geonorge register; when that fails the column staystextand the register URL goes into the column comment. - Enumerations become
CHECKconstraints, since a closed<<enumeration>>has no register to grow from. - Associations follow their multiplicities. A single-valued end becomes a
<role>_fkcolumn, many-to-many becomes a join table with its ownobjidand a unique pair of foreign keys. When only one end's multiplicity is known — sources other than XMI record just the far end — a join table is used, since it fits every case. An association to an abstract class references each concrete subtype. - Descriptions become
COMMENT ON TABLE/COMMENT ON COLUMN, and every foreign-key column is indexed. - External classes (
external: true, defined in another model) get no table, and associations to them are left out.
Table and column names are lowercased and transliterated to ASCII: æ→ae, ø→oe, å→aa, and anything else outside [a-z0-9_] becomes _, so Dyrkbar jord.Høyde becomes dyrkbar_jord.hoeyde. Names longer than PostgreSQL's 63-byte limit are shortened with a hash suffix rather than truncated, so they cannot collide. Code values and descriptions keep their original spelling.
From Python, postgis.build_postgis_ddl(feature_types, ...) returns the script and postgis.write_postgis_ddl(feature_types, path, ...) writes it. Both also take default_srid (default 25833, used when a feature type carries no storage CRS), allow_z, owner (the schema's AUTHORIZATION), read_role (granted SELECT on the schema's tables) and model_metadata (see below).
postgis.load_feature_types_from_postgis(path_or_url, schema=None) reads a .sql script back into feature types, the same structure the XMI, OGC API and GeoPackage loaders return. The script is parsed, not executed, so no database or driver is needed. As a scope source:
scopes:
- name: database
generator: postgis
schema: administrative_enheter # optional: one schema out of severalWithout a source, the script is read from the repository at inputs/<product-slug>/postgis.schema.sql, relative to the repository root (GITHUB_WORKSPACE in Actions, otherwise the working directory), where <product-slug> is the product specification's slug (product-slug/--slug, or derived from the title). The scope's source link then points at the file on GitHub. A missing file is an error. Give a source (a path or http(s) URL) to read a script from somewhere else.
Model metadata. Lowercased names and flattened columns lose information: lokalId becomes lokalid, identifikasjon.lokalId becomes the column identifikasjon_lokalid, and abstract supertypes have no table. So the writer also records the model as a last line in each table and column comment:
COMMENT ON COLUMN "kommune"."identifikasjon_lokalid" IS 'lokal identifikator av et objekt
@ps {"from":"SOSI_Fellesegenskaper","path":[{"name":"identifikasjon","type":"Identifikasjon",...},{"name":"lokalId",...}]}';The line holds the attribute's own keys (name, type, multiplicity, tagged values, value domain), its path through flattened data types, the class that declared it, and on feature tables the feature type itself plus the types that have no table. A script written with it reads back into exactly the feature catalogue it came from. Comments are part of the schema, so the metadata survives pg_dump --schema-only. Code values that the lookup tables or CHECK constraints already hold are not repeated. Pass model_metadata=False to leave the line out.
Other scripts. Without the @ps lines — pg_dump --schema-only output, full dumps (their COPY data fills the code lists), scripts from the Gistools PostGIS generator — the reader applies the writer's conventions in reverse: identifier/description tables are code lists, a table with one required foreign key that cascades from an owner (or is named <owner>_…) holds a repeating attribute, two foreign keys unique together form a many-to-many association, any other foreign key to a feature table is an association, CHECK (col IN …) is an enumeration, and objtype's default gives the class name. Geometry comes from geometry(Type, srid), AddGeometryColumn, or enforce_srid checks. Names then stay as the database has them, and flattened columns stay flat.
The parser splits statements on ; outside strings, quoted names, comments and dollar-quoted function bodies, and skips statements it does not need (functions, indexes, grants, SET). A recognised statement that does not parse raises an error naming it.
With pygeoapi-postgis-output: true (or --pygeoapi-postgis-output locally) the prepare action also writes <slug>.pygeoapi-postgis.yml: a pygeoapi configuration that publishes the tables of the PostGIS schema as OGC API - Features collections. It is generated from the same table model as <slug>.postgis.sql, so it is usually written together with postgis-output, and it uses postgis-schema as its search_path (default public).
Every realisable feature type — every concrete class, i.e. every class with a feature table in the PostGIS script — becomes a resources entry with one PostgreSQL provider:
| Field | Value |
|---|---|
| resource key | the class name, latinised, lowercase kebab-case: AdministrativEnhet → administrativ-enhet, Dyrkbar jord → dyrkbar-jord, ÅpenGrunn → aapen-grunn |
title / description |
the class name (or title) and its description |
keywords |
the class's keywords, else its title |
extents.spatial.bbox |
the dataset's extent from psdata (identificationSection.extent.geographicElement), in CRS84 |
data.dbname |
the slug, sanitised like the table names |
data.search_path |
postgis-schema, else public |
table / id_field |
the feature table and its primary key objid |
geom_field / storage_crs |
the class's geometry column and its SRID. With several geometries the one the class declares as its geometry is used — the PostgreSQL provider serves one. A class without geometry gets neither, nor a crs list. |
crs |
CRS84 and the common Norwegian/European CRSs (EPSG 25832/25833/25835, 5972/5973/5975, 3857, 3575, 4326, 3035, 4258, 5942), plus the storage CRS if it is not among them |
The service metadata comes from psdata: identification from the title, abstract, keywords, access constraints and metadata link; license from the licence and its URL; provider from the contact with role owner; contact from the contact with role pointOfContact.
Values only whoever runs the service knows — database host, port, user and password, and the provider's and contact's url — are written as "<REPLACE_ME>". So is any psdata value that is missing, rather than a guess. Fill them in before starting pygeoapi; pygeoapi also expands ${ENV_VAR} references, so the password can stay out of the file:
data:
host: ${POSTGRES_HOST}
port: 5432
dbname: dyrkbar_jord
user: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}From Python, pygeoapi_config.build_pygeoapi_postgis_config(feature_types, psdata=..., dbname=..., schema=...) returns the configuration as a dict and pygeoapi_config.write_pygeoapi_postgis_config(feature_types, path, ...) writes it. Pass the same default_srid/allow_z as to the PostGIS writer if you changed them there. The package is called pygeoapi_config so it does not shadow the pygeoapi package itself.
With pygeoapi-gpkg-output: true (or --pygeoapi-gpkg-output locally) the prepare action also writes <slug>.pygeoapi-gpkg.yml: the same configuration — server, service metadata, collection titles, descriptions, keywords, extents and CRSs — as for PostGIS, but every collection is served by pygeoapi's SQLiteGPKG provider from the GeoPackage instead of from PostgreSQL:
providers:
- type: feature
name: SQLiteGPKG
data: ./<slug>.gpkg
id_field: objid
table: Bygning
crs: [...]
storage_crs: http://www.opengis.net/def/crs/EPSG/0/25833It describes the GeoPackage that geopackage-output writes, so the two are usually turned on together, and the collections follow that GeoPackage's tables rather than the PostGIS ones:
datais the GeoPackage's file name,./<slug>.gpkg. pygeoapi resolves a relative path from its working directory, not from the configuration file, so adjust it to where the file (with data in it) is deployed.tableis the feature type's name as it is — GeoPackage tables are not lowercased or transliterated — andid_fieldis theobjidprimary key.- A class with several geometries is split into one table per geometry (
Bygning_flate,Bygning_punkt), as in the GeoPackage, and each becomes its own collection (bygning-flate,bygning-punkt). - There is no
geom_field: SQLiteGPKG finds the geometry column ingpkg_geometry_columnsitself. It also refuses a table without one, so a feature type without geometry (an aspatial attributes table in the GeoPackage) gets no collection. storage_crsis the table'ssrs_id.
SQLiteGPKG needs the SpatiaLite extension (mod_spatialite) on the pygeoapi host. It builds its SQL with the table name unquoted, so a table whose name contains a space (Dyrkbar jord) cannot be served — rename the table in the GeoPackage and in the configuration. Norwegian letters are fine.
From Python, pygeoapi_config.build_pygeoapi_gpkg_config(feature_types, psdata=..., data=...) returns the configuration and pygeoapi_config.write_pygeoapi_gpkg_config(feature_types, path, ...) writes it; pass the same default_crs/split_multi_geometry as to write_geopackage if you changed them there. geopackage.writer.realised_feature_tables(feature_types, ...) lists the feature tables write_geopackage creates, without writing anything.
The default template lives at data/template/ps.md.hbs. It expects the following placeholders to be populated by the generator:
incl_featuretypes_table: Markdown table generated from the OGC API feature catalogue metadata.incl_featuretypes_uml: Feature catalogue diagram rendered as a PNG when available, otherwise the raw PlantUML source.incl_featuretypes_xmi_table: Markdown table generated from the XMI feature catalogue when provided.incl_featuretypes_xmi_uml: XMI feature catalogue diagram rendered as a PNG when available, otherwise the raw PlantUML source.
During the assemble step, every additional *.md file placed alongside the generated artefacts (the same directory that holds psdata_<slug>.json) is automatically injected into the template. A file named innledning.md, for example, becomes available through the placeholder {{incl_innledning}}. Files that already have a dedicated input—such as index.md or <slug>_feature_catalogue.md—are ignored to avoid conflicts.
You can provide a customised template via the template-path input to tailor the resulting Markdown documentation.
Prepare artefacts locally:
python scripts/generate_product_spec.py <metadata-id> [<ogc-feature-api>] --output-dir produktspesifikasjon/test --skip-spec-markdownIf you want to filter the feature catalogue, pass --feature-type-filter multiple times or as a comma-separated list (applies to both OGC API and XMI sources):
python scripts/generate_product_spec.py <metadata-id> https://dirmin.no/kart/server/wfs3/collections \
--feature-type-filter Uttak --feature-type-filter KonsesjonTo split the specification into multiple scopes, pass a YAML/JSON payload (or a path to a YAML/JSON file) via scopes:
scopes:
- name: datafangst
source: https://sosi.geonorge.no/svn/SOSI/SOSI Del 3/Kommunal- og moderniseringsdepartementet/Arealplan/Arealplan 5.0/PlanleggingIgangsatt.xml
generator: xmi
description: Datamodellen brukes for å legge ved gml filer for planområdet som brukes i tjenesten for varsel om planoppstart.
- name: innsynstjeneste
source: https://plandata.ft-test.dibk.no/services/planleggingigangsatt/collections
generator: ogc_feature_api
description: Tjeneste for innsyn i planområder som er varslet for planlegging igangsatt.source is a path (relative to the working directory) or an http(s) URL to the data model; url is still accepted as an older name for it. generator is one of xmi, ogc_feature_api, geopackage and postgis (a .sql script — see Reading a PostGIS schema back).
Each scope writes its catalogue to <spec-directory>/<scope-name>/objektkatalog.md, and the main specification links to them.
If you have a SOSI UML XMI export instead of an OGC API, omit the second positional argument and pass --xmi-model <path-or-url> (optionally override the default sosi/sosi credentials with --xmi-username and --xmi-password). You can also omit the OGC API argument entirely to only fetch psdata (feature catalogue artefacts will still be created but remain empty). The generated files will use the _xmi_feature_catalogue.* suffix to keep them separate from OGC-based artefacts.
Once you have enriched the artefacts (e.g. rendered UML to PNG), assemble the final Markdown:
python scripts/assemble_product_spec.py produktspesifikasjon/test/<slug>/psdata_<slug>.json --output produktspesifikasjon/test/<slug>/index.md --feature-catalogue-markdown produktspesifikasjon/test/<slug>/<slug>_feature_catalogue.md --feature-catalogue-uml produktspesifikasjon/test/<slug>/<slug>_feature_catalogue.puml --feature-catalogue-png produktspesifikasjon/test/<slug>/<slug>_feature_catalogue.puml.pngAdjust paths to match your slug and any generated PNG files. The commands mirror the behaviour in GitHub Actions.
When you also generate an XMI-based catalogue, add the corresponding arguments:
--xmi-feature-catalogue-markdown produktspesifikasjon/test/<slug>/<slug>_xmi_feature_catalogue.md \
--xmi-feature-catalogue-uml produktspesifikasjon/test/<slug>/<slug>_xmi_feature_catalogue.pumland optionally --xmi-feature-catalogue-png <path> if you render the XMI diagram to PNG.
To produce schemas locally, generate the ShapeChange input, run the jar from an unpacked ShapeChange distribution, then evaluate the log:
python scripts/run_shapechange.py --mode generate \
--feature-catalogue produktspesifikasjon/test/<slug>/<slug>_feature_catalogue.json \
--target-namespace https://example.no/minmodell/1.0 \
--xmlns-prefix mm --schema-name MinModell \
--output-dir build/shapechange
cd /path/to/ShapeChange-4.0.0
java -Dfile.encoding=UTF-8 -jar ShapeChange-4.0.0.jar -c /abs/path/build/shapechange/shapechange-config.xml
cd -
python scripts/run_shapechange.py --mode check --output-dir build/shapechangeThe generated XSD lands in build/shapechange/xsd/ and the JSON Schema in build/shapechange/jsonschema/.
Support specification projects from Enterprise Architect XMI exports. You can convert those catalogues to the JSON structure expected by the rest of this repository via the xmi.feature_catalog module (or by passing --xmi-model to scripts/generate_product_spec.py):
import json
from pathlib import Path
from xmi.feature_catalog import load_feature_types_from_xmi
feature_types = load_feature_types_from_xmi(
"https://sosi.geonorge.no/svn/SOSI/SOSI%20Del%203/Statens%20kartverk/AdministrativeEnheter_FylkerOgKommuner-20240101.xml"
)
Path("feature_catalogue.json").write_text(json.dumps(feature_types, indent=2, ensure_ascii=False))The helper understands both local files and remote URLs. When downloading from sosi.geonorge.no the default sosi/sosi credentials are supplied automatically, but you can override them via the username and password arguments if needed. When the XMI path is used, generated artefacts follow the <slug>_xmi_feature_catalogue.* naming scheme to make side-by-side comparisons with OGC sources easier.