diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..2adad4e --- /dev/null +++ b/backend/README.md @@ -0,0 +1,330 @@ +# svg-service backend + +Backend for SVG scheme upload, draft editing, pricing, publish preview, and publish lifecycle. + +## Stack + +- Python 3.11 +- FastAPI +- SQLAlchemy async +- PostgreSQL 16 +- Docker Compose + +## Runtime + +Default backend port: `9020` + +Health check: + +- `GET /healthz` + +Main API prefix: + +- `/api/v1` + +Auth header: + +- `X-API-Key` + +Default local admin key: + +- `admin-local-dev-key` + +## Core lifecycle + +The backend works with a scheme lifecycle: + +1. Upload SVG +2. Normalize and persist structure +3. Work in current draft +4. Create / update pricing +5. Build pricing snapshot +6. Inspect publish preview / readiness +7. Publish current draft +8. If editing is needed after publish, create or ensure a new draft again + +## Main concepts + +### Scheme +Top-level business entity. + +### Scheme version +Concrete version of the scheme. +A version can be `draft` or `published`. + +### Current version +The version referenced by the scheme registry as active current. + +### Draft +Editable current version. +All editor mutations and draft pricing operations must target a current draft version only. + +### Published version +Non-editable current version. +If current version is published, editor flow must first create or ensure a new draft. + +### Upload artifacts +Stored technical artifacts, including: + +- original svg +- sanitized svg +- normalized json +- display svg +- publish preview json + +## Editor entry flow + +Use this flow from frontend or operator scripts. + +### 1. Inspect editor state + +`GET /api/v1/schemes/{scheme_id}/editor/context` + +Response tells whether: + +- current version is draft +- editor is available +- a new draft should be created +- recommended action is `use_current_draft` or `create_draft` + +### 2. Ensure editable draft + +`POST /api/v1/schemes/{scheme_id}/draft/ensure` + +Behavior: + +- if current version is already draft: returns it with `created=false` +- if current version is published: clones current version into a new current draft and returns it with `created=true` + +Returned `scheme_version_id` must be reused as: + +- `expected_scheme_version_id` + +for draft reads and mutations. + +## Optimistic concurrency + +Mutable draft flows support optimistic concurrency through query params: + +- `expected_current_scheme_version_id` +- `expected_scheme_version_id` + +These guards prevent frontend/editor from mutating a stale draft after another version switch. + +Typical typed conflict payload: + +- `stale_current_version` +- `stale_draft_version` +- `draft_not_editable` +- `publish_not_ready` + +## Main operator routes + +## System + +- `GET /healthz` +- `GET /api/v1/ping` +- `GET /api/v1/db/ping` +- `GET /api/v1/manifest` + +## Uploads + +- `POST /api/v1/schemes/upload` +- `GET /api/v1/uploads` +- `GET /api/v1/uploads/{upload_id}` +- `GET /api/v1/uploads/{upload_id}/normalized` + +## Scheme registry + +- `GET /api/v1/schemes` +- `GET /api/v1/schemes/{scheme_id}` +- `GET /api/v1/schemes/{scheme_id}/current` +- `GET /api/v1/schemes/{scheme_id}/versions` +- `POST /api/v1/schemes/{scheme_id}/versions` +- `GET /api/v1/schemes/{scheme_id}/publish/validation` +- `GET /api/v1/schemes/{scheme_id}/draft/publish-readiness` +- `POST /api/v1/schemes/{scheme_id}/publish` +- `POST /api/v1/schemes/{scheme_id}/unpublish` +- `POST /api/v1/schemes/{scheme_id}/rollback` + +## Editor / draft + +- `GET /api/v1/schemes/{scheme_id}/editor/context` +- `POST /api/v1/schemes/{scheme_id}/draft/ensure` +- `GET /api/v1/schemes/{scheme_id}/draft/summary` +- `GET /api/v1/schemes/{scheme_id}/draft/structure` +- `GET /api/v1/schemes/{scheme_id}/draft/validation` +- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview` +- `GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}` +- `GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}` +- `GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}` +- `POST /api/v1/schemes/{scheme_id}/draft/sectors` +- `POST /api/v1/schemes/{scheme_id}/draft/groups` +- `DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}` +- `DELETE /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}` +- `PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}` +- `POST /api/v1/schemes/{scheme_id}/draft/seats/bulk` +- `PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}` +- `PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}` +- `POST /api/v1/schemes/{scheme_id}/draft/repair-references` + +## Pricing + +- `GET /api/v1/schemes/{scheme_id}/pricing` +- `POST /api/v1/schemes/{scheme_id}/pricing/categories` +- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` +- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` +- `POST /api/v1/schemes/{scheme_id}/pricing/rules` +- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` +- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` + +## Pricing diagnostics + +- `GET /api/v1/schemes/{scheme_id}/pricing/coverage` +- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats` +- `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}` +- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics` + +## Publish preview + +- `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot` +- `GET /api/v1/schemes/{scheme_id}/draft/publish-preview` +- `POST /api/v1/schemes/{scheme_id}/draft/remap/preview` +- `POST /api/v1/schemes/{scheme_id}/draft/remap/apply` + +## Structure read model + +- `GET /api/v1/schemes/{scheme_id}/current/sectors` +- `GET /api/v1/schemes/{scheme_id}/current/groups` +- `GET /api/v1/schemes/{scheme_id}/current/seats` +- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price` +- `GET /api/v1/schemes/{scheme_id}/current/svg` +- `GET /api/v1/schemes/{scheme_id}/current/svg/display` +- `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta` + +## Test mode + +- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}` + +## Audit + +- `GET /api/v1/schemes/{scheme_id}/audit` + +## Admin / ops + +- `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts` +- `GET /api/v1/admin/schemes/{scheme_id}/current/validation` +- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate` +- `POST /api/v1/admin/display/backfill` +- `GET /api/v1/admin/artifacts/publish-preview/audit` +- `POST /api/v1/admin/artifacts/publish-preview/cleanup` + +## Typical local flow + +## 1. Read current version + +Use: + +`GET /api/v1/schemes/{scheme_id}/current` + +## 2. Ensure draft + +Use: + +`POST /api/v1/schemes/{scheme_id}/draft/ensure` + +Store returned: + +- `scheme_version_id` + +## 3. Read draft state + +Use: + +- `GET /draft/summary?expected_scheme_version_id=...` +- `GET /draft/structure?expected_scheme_version_id=...` +- `GET /draft/validation?expected_scheme_version_id=...` +- `GET /draft/compare-preview?expected_scheme_version_id=...` + +## 4. Perform editor mutations + +Pass: + +- `expected_scheme_version_id={draft_scheme_version_id}` + +on every mutation route. + +## 5. Inspect pricing quality + +Use: + +- `GET /pricing/coverage` +- `GET /pricing/unpriced-seats` +- `GET /pricing/explain/{seat_id}` +- `GET /pricing/rules/diagnostics` + +## 6. Create pricing snapshot + +Use: + +`POST /draft/pricing/snapshot?expected_scheme_version_id=...` + +## 7. Inspect readiness + +Use: + +`GET /draft/publish-readiness?expected_scheme_version_id=...` + +## 8. Publish + +Use: + +`POST /publish?expected_scheme_version_id=...` + +## Typed error expectations + +Examples of stable typed errors already used in the service: + +### Draft concurrency/state +- `stale_current_version` +- `stale_draft_version` +- `draft_not_editable` + +### Editor validation +- `duplicate_seat_id` +- `duplicate_seat_id_in_payload` +- `duplicate_sector_id` +- `duplicate_group_id` +- `duplicate_sector_element_id` +- `duplicate_group_element_id` +- `unknown_sector_id` +- `unknown_group_id` +- `unknown_sector_ids` +- `unknown_group_ids` +- `unknown_target_sector_id` +- `unknown_target_group_id` +- `remap_filter_required` + +### Pricing / publish +- `invalid_amount` +- `publish_not_ready` + +## Draft summary semantics + +`GET /draft/summary` is the compact route for editor bootstrap. + +It returns: + +- current draft counters +- validation summary +- structure diff summary +- publish readiness summary + +This route is intended for frontend side panels / header status / quick preflight. + +## Notes + +- Draft-only routes must not mutate a published current version. +- Published current version should require `draft/ensure` before any editor mutation. +- Publish readiness can fail even if validation passes, for example when pricing snapshot is missing. +- `api-map.md` and `smoke-regression.md` must be updated together with route changes. diff --git a/backend/app/api/routes/structure.py b/backend/app/api/routes/structure.py index 66213a6..7f57e96 100644 --- a/backend/app/api/routes/structure.py +++ b/backend/app/api/routes/structure.py @@ -244,7 +244,7 @@ async def get_effective_seat_price(scheme_id: str, seat_id: str, role: str = Dep matched_rule_level=matched_rule_level, matched_target_ref=rule["target_ref"], pricing_category_id=rule["pricing_category_id"], - amount=rule["amount"], + amount=str(rule["amount"]), currency=rule["currency"], ) diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index e0100b7..6914f7c 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -21,6 +21,7 @@ - GET /api/v1/schemes/{scheme_id}/versions - POST /api/v1/schemes/{scheme_id}/versions - GET /api/v1/schemes/{scheme_id}/publish/validation +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness - POST /api/v1/schemes/{scheme_id}/publish - POST /api/v1/schemes/{scheme_id}/unpublish - POST /api/v1/schemes/{scheme_id}/rollback @@ -58,7 +59,6 @@ ## app/api/routes/publish.py - POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot - GET /api/v1/schemes/{scheme_id}/draft/publish-preview -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness - POST /api/v1/schemes/{scheme_id}/draft/remap/preview - POST /api/v1/schemes/{scheme_id}/draft/remap/apply @@ -94,4 +94,4 @@ - This file is an operational route index, not a generated OpenAPI export. - Update this map in the same change set when adding, removing, renaming, or moving routes. - Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows. -- Draft/editor routes may legally return 409 draft_not_editable when current version is already published and no editable draft exists yet. +- Draft editor flow starts from editor/context and draft/ensure, not from direct blind mutation calls. diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index c2e0c98..c5a7851 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -31,19 +31,78 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772" - GET /api/v1/schemes/{scheme_id}/current -> 200 - GET /api/v1/schemes/{scheme_id}/versions -> 200 -## 3. Structure read model +Validate: +- scheme_id is stable +- current version exists +- version list contains current version +- status and counts are consistent + +## 3. Editor entry flow + +- GET /api/v1/schemes/{scheme_id}/editor/context -> 200 +- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200 + +Validate: +- editor context returns current_scheme_version_id +- editor context distinguishes draft vs published state correctly +- ensure endpoint is idempotent on current draft +- ensure endpoint creates a new draft from published current when needed +- returned scheme_version_id is reusable as expected_scheme_version_id + +## 4. Draft read model + +Using current draft version id from draft/ensure: + +- GET /api/v1/schemes/{scheme_id}/draft/summary?expected_scheme_version_id={draft_version_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/structure?expected_scheme_version_id={draft_version_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/validation?expected_scheme_version_id={draft_version_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/compare-preview?expected_scheme_version_id={draft_version_id} -> 200 + +Validate: +- summary returns total_seats / total_sectors / total_groups +- summary returns validation_summary / structure_diff_summary / publish_readiness +- structure returns lists for seats / sectors / groups +- validation is deterministic +- compare preview returns stable diff structure +- stale expected_scheme_version_id returns typed 409 conflict + +## 5. Draft entity reads + +- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 + +Validate: +- record endpoints return exact draft entities +- unknown record id returns 404 +- stale expected_scheme_version_id returns typed 409 conflict + +## 6. Structure read model - GET /api/v1/schemes/{scheme_id}/current/sectors -> 200 - GET /api/v1/schemes/{scheme_id}/current/groups -> 200 - GET /api/v1/schemes/{scheme_id}/current/seats -> 200 -## 4. SVG / display pipeline +Validate: +- total counts are non-negative +- known sample scheme returns expected object lists +- seats contain seat_id / sector_id / group_id contract where applicable + +## 7. SVG / display pipeline - GET /api/v1/schemes/{scheme_id}/current/svg -> 200 - GET /api/v1/schemes/{scheme_id}/current/svg/display -> 200 - GET /api/v1/schemes/{scheme_id}/current/svg/display/meta -> 200 +- GET /api/v1/schemes/{scheme_id}/current/svg/display?mode=optimized -> 200 or explicit controlled failure +- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta?mode=optimized -> 200 or explicit controlled failure -## 5. Pricing read model / diagnostics +Validate: +- response content type for svg endpoints is image/svg+xml +- meta returns scheme_id, scheme_version_id, view_box, width, height +- no 500 on passthrough mode +- unsupported mode returns 422 + +## 8. Pricing read model - GET /api/v1/schemes/{scheme_id}/pricing -> 200 - GET /api/v1/schemes/{scheme_id}/pricing/coverage -> 200 @@ -51,71 +110,113 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772" - GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} -> 200 - GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics -> 200 - GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 for priced seat -- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for priced and unpriced seat - -## 6. Editor entry workflow - -- GET /api/v1/schemes/{scheme_id}/editor/context -> 200 always -- if context.needs_new_draft=true -> POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200 -- after ensure -> GET /api/v1/schemes/{scheme_id}/editor/context -> 200 and editable=true +- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known seat Validate: -- published current version does not break editor bootstrap -- ensure returns created_new_draft=true when current was published -- ensure returns created_new_draft=false when current was already draft +- pricing bundle contains categories and rules arrays +- coverage values are internally consistent +- unpriced seats list explains reason_code / reason_message +- explain endpoint shows matched rule for priced seat and null for unpriced seat +- diagnostics returns orphan/active rule visibility +- test seat preview explains selectable / has_price state +- priced test seat amount is serialized as string -## 7. Draft editor read model +## 9. Draft mutations and validation guards -- GET /api/v1/schemes/{scheme_id}/draft/summary -> 200 when current version is draft -- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200 when current version is draft -- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200 when current version is draft -- GET /api/v1/schemes/{scheme_id}/draft/compare-preview -> 200 when current version is draft -- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 +For current draft version: -## 8. Draft mutations - -- POST /api/v1/schemes/{scheme_id}/draft/sectors -> 200 or typed 422 conflict -- POST /api/v1/schemes/{scheme_id}/draft/groups -> 200 or typed 422 conflict -- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 or typed 422 validation error -- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk -> 200 or typed 422 validation error -- PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 -- PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 +- POST /api/v1/schemes/{scheme_id}/draft/sectors -> 200 or 422 +- POST /api/v1/schemes/{scheme_id}/draft/groups -> 200 or 422 +- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 or 422 +- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk -> 200 or 422 +- POST /api/v1/schemes/{scheme_id}/draft/remap/preview -> 200 or 422 +- POST /api/v1/schemes/{scheme_id}/draft/remap/apply -> 200 or 422 - POST /api/v1/schemes/{scheme_id}/draft/repair-references -> 200 -- POST /api/v1/schemes/{scheme_id}/draft/remap/preview -> 200 or typed 422 validation error -- POST /api/v1/schemes/{scheme_id}/draft/remap/apply -> 200 or typed 422 validation error -## 9. Publish preview / readiness +Validate: +- duplicate ids return typed 422 +- duplicate element binding returns typed 422 +- unknown sector/group references return typed 422 +- remap without filters returns typed 422 +- stale expected_scheme_version_id returns typed 409 +- published current version rejects draft mutations with typed draft_not_editable conflict + +## 10. Draft publish preview -- GET /api/v1/schemes/{scheme_id}/publish/validation -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 when current version is draft - POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot -> 200 when scheme is in draft - GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true -> 200 - GET /api/v1/schemes/{scheme_id}/draft/publish-preview -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true&baseline_scheme_version_id={published_version_id} -> 200 -## 10. Publish lifecycle +Validate: +- refresh and cached read both succeed +- preview summary contains is_publishable / has_structure_changes / has_artifacts / snapshot_available +- pricing_coverage is internally consistent +- baseline override returns override strategy when explicit baseline is provided +- preview retention does not grow unbounded for same version+variant -- POST /api/v1/schemes/{scheme_id}/publish -> 200 when draft is ready -- POST /api/v1/schemes/{scheme_id}/publish with stale expected_scheme_version_id -> 409 -- POST /api/v1/schemes/{scheme_id}/unpublish -> 200 -- POST /api/v1/schemes/{scheme_id}/rollback -> 200 +## 11. Publish readiness and publish flow -## 11. Admin / ops +For current draft version: + +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 +- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={draft_version_id} -> 200 or 409 + +Validate: +- readiness explicitly shows snapshot_available and pricing gate state +- publish with stale expected version returns typed 409 +- publish without draft state returns typed 409 +- publish success updates current status to published +- audit trail contains scheme.published event + +## 12. Admin / ops - GET /api/v1/admin/schemes/{scheme_id}/current/artifacts -> 200 - GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200 - GET /api/v1/admin/artifacts/publish-preview/audit -> 200 - POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200 -## 12. Audit trail +Optional: +- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate?mode=passthrough -> 200 +- POST /api/v1/admin/display/backfill?mode=passthrough&limit=10&only_missing=true -> 200 + +Validate: +- audit endpoint does not report orphan files or missing files for DB rows in normal state +- validation report is readable and deterministic +- admin routes do not produce 500 for healthy scheme state + +## 13. Audit trail - GET /api/v1/schemes/{scheme_id}/audit -> 200 -## 13. Fail criteria +Validate: +- recent publish preview / pricing / version / publish events are present when corresponding operations were run +- audit total is non-negative +- event payloads stay JSON-serializable -Regression is considered failed if: +## 14. Fail criteria + +Regression is considered failed if any of the following happen: + +- health or db ping fails - any stable read endpoint returns 500 -- published current version cannot be converted to draft through ensure flow -- draft editor summary/read endpoints return inconsistent data -- publish-state mutation guard is bypassed +- passthrough display endpoint fails on known-good sample +- publish preview refresh or cached read returns 500 +- publish readiness returns 500 +- editor context or draft ensure returns 500 +- draft summary / structure / validation / compare-preview returns 500 +- pricing bundle or diagnostics contract changes unexpectedly +- admin audit/cleanup endpoints fail on healthy environment +- artifact retention grows without bound for repeated preview refresh on same variant + +## 15. Operator note + +Run this checklist after: +- schema changes +- pricing schema/repository refactors +- artifact lifecycle changes +- display pipeline changes +- route reorganization +- startup/import/config changes +- draft lifecycle changes +- publish readiness changes diff --git a/backend/scripts/smoke_regression.sh b/backend/scripts/smoke_regression.sh new file mode 100755 index 0000000..654e541 --- /dev/null +++ b/backend/scripts/smoke_regression.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +API_URL="${API_URL:-http://127.0.0.1:9020}" +API_KEY="${API_KEY:-admin-local-dev-key}" +SCHEME_ID="${SCHEME_ID:-82086336d385427f9d56244f9e1dd772}" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +HDR_AUTH=(-H "X-API-Key: ${API_KEY}") +HDR_JSON=(-H "Content-Type: application/json") + +step() { + echo + echo "===== $1 =====" +} + +fail() { + echo + echo "[FAIL] $1" >&2 + exit 1 +} + +request() { + local name="$1" + local method="$2" + local url="$3" + local expected="$4" + local body="${5:-}" + local outfile="${WORKDIR}/${name}.body" + local codefile="${WORKDIR}/${name}.code" + + if [[ -n "$body" ]]; then + curl -sS \ + -X "$method" \ + "${HDR_AUTH[@]}" \ + "${HDR_JSON[@]}" \ + -o "$outfile" \ + -w "%{http_code}" \ + "$url" \ + --data "$body" > "$codefile" + else + curl -sS \ + -X "$method" \ + "${HDR_AUTH[@]}" \ + -o "$outfile" \ + -w "%{http_code}" \ + "$url" > "$codefile" + fi + + local code + code="$(cat "$codefile")" + + echo "[$method] $url -> $code" + cat "$outfile" + echo + + if [[ "$code" != "$expected" ]]; then + fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}" + fi +} + +json_get() { + local file="$1" + local expr="$2" + python3 - <