Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions composite-actions/fiscal/openapi-actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# openapi-actions

Builds the deployable, country-scoped OpenAPI spec for a fiscal adapter, then re-seals the jar so
the running service serves it. In one pass it:

1. **Materializes** the engine's shared `openapi.yaml` out of the `fiscal-engine` dependency into
the build output — the adapter ships no spec of its own.
2. **Stamps `info.version`** from the optional `version` input (the engine ships it as a literal
`${project.version}` token so each adapter sets its own build version — see below).
3. **Country-filters** it in place: a Redocly decorator strips every other country's
`x-country`-tagged `oneOf` branch, leaving only this country's `attributes` shape.
4. **Re-runs `jar:jar`** so the already-built jar picks up the overwritten resource.

The engine serves whatever classpath resource ends up at `/schemas/v1/openapi.yaml` verbatim, and
the adapter jar precedes `fiscal-engine.jar` on the classpath — so overwriting that path is enough
to make e.g. Belgium's live service serve a Belgium-only, correctly-versioned spec. **No engine
code change and no per-adapter `pom.xml` config needed.**

The engine ships one canonical spec whose response `attributes` are `Fiscalize-Attributes` and
`Cancel-Attributes` `oneOf`s — one `x-country`-tagged branch per country
(`src/main/resources/schemas/v1/openapi.yaml` in `hiiretail-fiscal-engine`).

This is a shared composite action
(`extenda/shared-workflows/composite-actions/fiscal/openapi-actions`), used by all country repos
that fiscalize via `fiscal-engine`.

## Usage

Run this any time **after** a normal `mvn package` — it re-seals the jar for you, so callers don't
need to reorder their own build around it. It does no checkout of its own (an `actions/checkout`
step here would run `git clean -ffdx` by default and wipe `target/`), so it must run later in the
same job as the checkout and package steps, not a separate job.

```yaml
- run: ./mvnw package -DskipTests
- uses: extenda/shared-workflows/composite-actions/fiscal/openapi-actions@v0
with:
country: belgium
version: ${{ steps.semver.outputs.version }} # optional; omit for local/acceptance runs
- run: docker build -t $IMAGE . # picks up the re-jarred, versioned, Belgium-only target/*.jar
```

## Inputs

| Input | Required | Default | Description |
|-------|----------|---------|-------------|
| `country` | yes | – | The lower-case api name from this action's `redocly.yaml` `apis:` map, e.g. `belgium`. |
| `version` | no | `''` | When set, replaces the engine spec's literal `${project.version}` token so the served `info.version` is the adapter's build version. When empty, the token is left as-is (fine for local/acceptance runs, where the version is cosmetic). |
| `schema-dir` | no | `target/classes/schemas/v1` | Directory (relative to the workspace root) the spec is materialized into and filtered in place. |

## Adding a country

One identifier throughout: the lower-case country name used as the `apis:` key (e.g. `belgium`) is
reused verbatim as both the decorator's `country` option and the `x-country` tag value — no
separate short code to keep in sync.

1. In the engine's `openapi.yaml`, add a `Fiscalize-Attributes-<Country>` branch to the
`Fiscalize-Attributes` `oneOf` and a `Cancel-Attributes-<Country>` branch to the
`Cancel-Attributes` `oneOf`, each tagged `x-country: <country>` (lower-case, e.g.
`x-country: portugal`).
2. In this action's `redocly.yaml`, add a sibling `apis:` entry named `<country>` with
`openapi/strip-other-countries: { country: <country> }`.

## Notes / validation status

- **Version is set by a decorator, not text substitution.** The action forwards the `version`
input into the bundle container as `API_VERSION`; the `openapi/set-version` decorator reads it
and overrides `info.version`, logging `openapi: set info.version = <v>`. Empty ⇒ no-op.
- **Decorators only run during `bundle`, not `lint`** — linting the pre-bundle source wouldn't
exercise the transformation, so this action lints the *bundled* output instead.
- `redocly.yaml` and `decorators/` live in this action's own repo, not the caller's checkout —
`docker run -v $PWD:/spec` only mounts the caller's workspace, so they're staged (`cp`) into
`schema-dir` via `${{ github.action_path }}` before bundling. The staging step clears any
pre-existing `redocly.yaml`/`decorators/` at the destination first, so re-runs on a reused
workspace can't leave a stale or nested `decorators/decorators/` copy behind.
- Bundling reads and writes `openapi.yaml` at the same path — safe because the bundler loads the
whole document graph into memory before it writes any output.
- A single surviving `oneOf` branch (e.g. Belgium today, with only one real country onboarded) is
collapsed by the decorator into the wrapper schema directly, rather than left as a `oneOf` of one.
- `jar:jar` only re-zips already-compiled `target/classes` — no recompile, no test run — so it's
cheap to run as a dedicated step after packaging.
- **Not yet validated against a real country pipeline in CI** — verified locally by running every
step's command (materialize, stage, in-place bundle with `API_VERSION`, lint, `jar:jar`) against
`hiiretail-fiscal-engine`'s freshly-built spec: confirmed the bundle ships both
`Fiscalize-Attributes-BE`/`-EU` and `Cancel-Attributes-BE`/`-EU` before, and only the collapsed
Belgium schemas plus the stamped `info.version` after. Confirm on a first real run in an actual
country pipeline before relying on it for prod.
84 changes: 84 additions & 0 deletions composite-actions/fiscal/openapi-actions/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: 'Fiscal API country filtering'
description: >-
Materializes the fiscal-engine's shared openapi.yaml from the fiscal-engine dependency,
optionally stamps `info.version`, then replaces it IN PLACE with a country-scoped bundle that
strips every other country's `x-country`-tagged documentation via a Redocly decorator (see the
engine's `Fiscalize-Attributes`/`Cancel-Attributes` oneOf, src/main/resources/schemas/v1/openapi.yaml),
and re-runs `jar:jar` so the already-built jar picks up the change. The engine serves whatever
classpath resource ends up at /schemas/v1/openapi.yaml verbatim — no engine code and no per-country
pom change needed. Run it after a normal `mvn package`. Does no checkout of its own (an
`actions/checkout` step here would run `git clean -ffdx` by default and wipe `target/`).

inputs:
country:
required: true
description: The lower-case api name from this action's redocly.yaml `apis:` map, e.g. 'belgium'.
version:
required: false
description: >-
When set, replaces the engine spec's literal `${project.version}` token so the served
`info.version` is the adapter's build version. When empty, the token is left as-is.
default: ''
schema-dir:
required: false
description: Directory (relative to the workspace root) containing the built openapi.yaml.
default: 'target/classes/schemas/v1'

runs:
using: composite
steps:
# The adapter ships no spec of its own — the engine dependency owns openapi.yaml. Unpack it into
# the build output so the steps below (and the re-jar) operate on a real file. Same pattern the
# callers already use for the engine's Liquibase changesets.
- name: Materialize the engine's OpenAPI spec
shell: bash
run: |
./mvnw -q dependency:unpack-dependencies \
-DincludeArtifactIds=fiscal-engine \
-Dincludes=schemas/v1/openapi.yaml \
-DoutputDirectory=target/openapi-engine-spec
mkdir -p "${{ inputs.schema-dir }}"
cp target/openapi-engine-spec/schemas/v1/openapi.yaml "${{ inputs.schema-dir }}/openapi.yaml"

# redocly/cli is pinned by digest below for immutability. Dependabot's docker ecosystem only
# scans Dockerfile/docker-compose.yml `FROM`/`image:` fields, not `docker run` invocations
# inside a composite action's shell script — this is NOT auto-updated. Check
# https://hub.docker.com/r/redocly/cli/tags periodically and bump both occurrences by hand.

# redocly.yaml and decorators/ live in THIS action's own repo (extenda/shared-workflows), not
# the caller's checkout — `docker run -v $PWD:/spec` only mounts the caller's workspace, so
# they have to be staged alongside the spec before redocly can see them.
- name: Stage the country-filter config next to the built spec
shell: bash
run: |
rm -rf "${{ inputs.schema-dir }}/redocly.yaml" "${{ inputs.schema-dir }}/decorators"
cp "${{ github.action_path }}/redocly.yaml" "${{ inputs.schema-dir }}/redocly.yaml"
cp -r "${{ github.action_path }}/decorators" "${{ inputs.schema-dir }}/decorators"

# Bundles openapi.yaml and overwrites it in place with the country-filtered result — the
# bundler reads the whole document graph before it writes any output, so reading and writing
# the same path is safe.
# API_VERSION is passed through to the container's env (docker `-e NAME` forwards the host
# value); the `set-version` decorator reads it and overrides info.version. Empty => no-op.
- name: Bundle the ${{ inputs.country }} spec in place
shell: bash
env:
API_VERSION: ${{ inputs.version }}
run: |
docker run --rm -e API_VERSION -v "$PWD/${{ inputs.schema-dir }}:/spec" redocly/cli:2.35.1@sha256:404c57d791f2a9d08973bb568da2e070bbc7bb96c94248eba38493537b78abb5 \
bundle ${{ inputs.country }} --config redocly.yaml -o openapi.yaml

# Decorators only run during `bundle`, not `lint` — linting the pre-bundle source wouldn't
# exercise the country-filtering transformation at all, so lint the bundled (now in-place)
# result instead.
- name: Lint the bundled ${{ inputs.country }} spec
shell: bash
run: |
docker run --rm -v "$PWD/${{ inputs.schema-dir }}:/spec" redocly/cli:2.35.1@sha256:404c57d791f2a9d08973bb568da2e070bbc7bb96c94248eba38493537b78abb5 lint openapi.yaml

# target/classes/schemas/v1/openapi.yaml was already zipped into the jar by the caller's own
# `mvn package`. jar:jar just re-zips target/classes — no recompile, no test run — so the jar
# on disk picks up the file we just overwrote.
- name: Re-jar with the filtered spec
shell: bash
run: ./mvnw jar:jar
104 changes: 104 additions & 0 deletions composite-actions/fiscal/openapi-actions/decorators/openapi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// For any component schema with a `oneOf` whose branches are tagged `x-country` on the branch
// component itself, keeps only the branch matching the configured `country` and deletes now-
// unreferenced x-country-tagged components. Modeled on Redocly's built-in `remove-x-internal`
// decorator (walk components.schemas, delete tagged nodes).
function StripOtherCountries({ country }) {
if (!country) {
throw new Error('openapi/strip-other-countries requires a `country` option');
}
return {
Root: {
leave(root) {
const schemas = root?.components?.schemas;
if (!schemas) return;

for (const [schemaName, schema] of Object.entries(schemas)) {
if (!Array.isArray(schema?.oneOf)) continue;

const before = schema.oneOf;
schema.oneOf = before.filter((branch) => {
const branchName = refName(branch?.$ref);
const branchCountry = branchName && schemas[branchName]?.['x-country'];
return !branchCountry || branchCountry === country;
});

for (const branch of before) {
if (!schema.oneOf.includes(branch)) {
console.log(
`openapi: removed '${refName(branch?.$ref)}' from '${schemaName}.oneOf' (x-country != ${country})`
);
}
}

if (schema.oneOf.length === 0) {
throw new Error(
`openapi: no branch of '${schemaName}' tagged x-country: ${country} — add ` +
`one before configuring redocly.yaml's '${country}' apis entry.`
);
}

// A single surviving branch makes the oneOf wrapper pointless — collapse the branch's
// content into the wrapper itself so consumers see one plain schema, not a oneOf of one.
if (schema.oneOf.length === 1) {
const branchName = refName(schema.oneOf[0].$ref);
const branchSchema = schemas[branchName];
delete schema.oneOf;
Object.assign(schema, branchSchema);
delete schema['x-country'];
delete schema['title'];
console.log(`openapi: '${branchName}' is copied into '${schemaName}'`);
}
}

for (const [schemaName, schema] of Object.entries(schemas)) {
if (schema?.['x-country'] && !isReferenced(schemas, schemaName)) {
delete schemas[schemaName];
console.log(`openapi: dropped unreferenced component '${schemaName}'`);
}
}
}
}
};
}

function refName(ref) {
return typeof ref === 'string' && ref.startsWith('#/components/schemas/')
? ref.slice('#/components/schemas/'.length)
: undefined;
}

// ponytail: string-scan ref check, not a real ref-graph walk — fine while component names are
// unique tokens; swap for a proper walker if a name ever collides as a substring of another ref.
function isReferenced(schemas, name) {
return JSON.stringify(schemas).includes(`#/components/schemas/${name}`);
}

// Overrides info.version from the API_VERSION env var when set, so each adapter stamps its
// own build version onto the shared engine spec as part of the same bundle pass — no pom filtering
// or text substitution. No-op when the env var is empty (the engine's literal ${project.version}
// token is left in place, which is fine for local/acceptance runs where the version is cosmetic).
function SetVersion() {
return {
Info: {
leave(info) {
const version = process.env.API_VERSION;
if (version) {
info.version = version;
console.log(`openapi: set info.version = ${version}`);
}
}
}
};
}

module.exports = function openapiPlugin() {
return {
id: 'openapi',
decorators: {
oas3: {
'strip-other-countries': StripOtherCountries,
'set-version': SetVersion
}
}
};
};
12 changes: 12 additions & 0 deletions composite-actions/fiscal/openapi-actions/redocly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
extends: [recommended]

plugins: ['decorators/openapi.js']

# Applies to every api below: stamps info.version from the API_VERSION env var when set.
decorators:
openapi/set-version: on

apis:
belgium:
root: openapi.yaml
decorators: { openapi/strip-other-countries: { country: belgium } }
83 changes: 83 additions & 0 deletions composite-actions/fiscal/spanner-pgadapter-execute-sql/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: 'Spanner (PG-dialect) execute SQL'
description: >-
Execute caller-supplied SQL against a PostgreSQL-dialect Cloud Spanner database
by fronting it with PGAdapter and using the standard psql client. Domain-agnostic
(the SQL is an input). A sibling of spanner-pgadapter-liquibase for ad-hoc DML/DDL.

inputs:
service-account-key:
description: 'GCP service-account key (JSON) with access to the Spanner database.'
required: true
project:
description: 'GCP project hosting the Spanner instance.'
required: true
instance:
description: 'Spanner instance id.'
required: false
default: 'fiscal-signing'
database:
description: 'Spanner database id (must be PostgreSQL dialect).'
required: true
sql:
description: 'SQL to execute. May contain multiple semicolon-separated statements.'
required: true
pgadapter-image:
description: >-
PGAdapter image. Pinned by digest for immutability (Dependabot's docker ecosystem only
scans Dockerfile/docker-compose.yml, not action.yaml inputs, so this is NOT auto-updated —
check https://console.cloud.google.com/artifacts/docker/cloud-spanner-pg-adapter/us/gcr.io/pgadapter
periodically and bump both the tag and digest together by hand.
required: false
default: 'gcr.io/cloud-spanner-pg-adapter/pgadapter:v0.55.0@sha256:30bb42ded681effccf83f93e5f5107c1ef9e5e48513d0f7e1d30c7bfc69fa5fa'

runs:
using: composite
steps:
# Authenticate via the trusted action. It writes the credential file, registers it for
# log masking, and exports GOOGLE_APPLICATION_CREDENTIALS — so the key value is never
# interpolated into a shell command or handled by this action directly.
- uses: extenda/actions/setup-gcloud@v0
with:
service-account-key: ${{ inputs.service-account-key }}

- name: Start PGAdapter
shell: bash
run: |
if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then
echo "GOOGLE_APPLICATION_CREDENTIALS not set; setup-gcloud must run first" >&2
exit 1
fi
# Mount the credential FILE (a path, not the secret value) and let PGAdapter pick it
# up via Application Default Credentials.
docker run -d --name pgadapter \
-p 5432:5432 \
-v "${GOOGLE_APPLICATION_CREDENTIALS}:/var/run/secrets/gcp-credentials.json:ro" \
-e GOOGLE_APPLICATION_CREDENTIALS=/var/run/secrets/gcp-credentials.json \
"${{ inputs.pgadapter-image }}" \
-p "${{ inputs.project }}" -i "${{ inputs.instance }}" -x
# PGAdapter is the last thing to open the port; wait until it accepts TCP.
for i in $(seq 1 30); do
if (exec 3<>/dev/tcp/localhost/5432) 2>/dev/null; then echo "PGAdapter ready"; exit 0; fi
sleep 1
done
echo "PGAdapter did not become ready" >&2
docker logs pgadapter || true
exit 1

- name: Execute SQL
shell: bash
# SQL is passed via the environment and piped to psql's stdin, so its contents are
# never interpolated into the command line. PGAdapter authenticates to Spanner via the
# mounted key; the client-side username/password are unused.
env:
SQL: ${{ inputs.sql }}
DATABASE: ${{ inputs.database }}
run: |
printf '%s\n' "${SQL}" | psql \
"postgresql://spanner:spanner@localhost:5432/${DATABASE}?sslmode=disable" \
-v ON_ERROR_STOP=1

- name: Stop PGAdapter
if: always()
shell: bash
run: docker rm -f pgadapter || true
Loading