Skip to content

API v1 to v2 Migration

Преподобный Ален edited this page Sep 25, 2026 · 2 revisions

API v1 → API v2: the migration procedure

Audience: AI agents and engineers who move an Apostol CRM application from /api/v1 (routes dispatched inside PostgreSQL by rest.*) to /api/v2 (routes served by a Go process built on go-platform). Read Development-Strategy first: it says why; this page says how, in an order that has been executed once (Apostol CSMS, September 2026) and is meant to be executed again on every project.

Status of this page: a regulation. Deviations are allowed only by a recorded decision of the project owner; write the deviation next to the step it changes.


0. The two request paths

v1   client ── POST /api/v1/<path> ──► AppServer (C++) ──► daemon.fetch ──► api.run ──► rest.<route>() ──► api.* ──► JSON envelope, 200 always
v2   client ── GET|POST|PATCH|DELETE /api/v2/<resource> ──► GatewayAPI (C++) ── HTTP ──► Go process (go-platform host) ──► daemon.* helper ──► api.* ──► HTTP status, problem+json on error
v1 v2
Who parses the HTTP request PostgreSQL (rest.*: ~27 k lines of PL/pgSQL for routing, field checks, envelopes) Go (typed handlers, lib/rest, lib/query)
Who authenticates AppServer validates the token, daemon.fetch sets the session GatewayAPI verifies the JWT, forwards Authorization: Bearer; the Go host verifies the signature again before any route; the daemon.* helper validates the token and establishes the session inside the transaction (transitional: api.authorize[_local] under apibot)
Database role daemon (sees schema daemon only) daemon (sees schema daemon only; the daemon.* functions are SECURITY DEFINER and call api.*). Transitional: apibot (sees schema api only: EXECUTE on api.*, SELECT on api.* views, schema gateway)
Business logic api.* → kernel (PL/pgSQL) unchanged: the same api.* functions, called through the daemon.* helper (transitional: directly)
Transaction one api.run = one transaction one HTTP request = one transaction (lib/pgtx)
Errors {error: {code, message}} inside a 200 application/problem+json, HTTP status from the error catalogue
Events / subscriptions WebSocketAPI unchanged: WebSocketAPI; /api/v2/* over WebSocket is refused by design

Invariant of the whole migration: the database's business logic, data model, access rules, workflow, OCPP/OCPI and payment subsystems do not change. The database patch for v2 is the gateway module plus the daemon.* helpers the Go process calls (transitional path: api.authorize_local, api.log_request, gateway.node, grants to apibot) and nothing else. If a step below seems to require a change in kernel or in an api.* signature, the step is wrong or the api.* function has a defect that v1 was hiding — file it against the database layer, do not work around it in Go.


1. Prerequisites — check, do not assume

# Prerequisite How to verify
1 db-platform version with the gateway module; platform.DBPlatform in go-platform names the version the integration tests last ran against cat db/sql/platform/VERSION equals platform.DBPlatform; a mismatch is a red integration test, not a warning
2 The process's role reaches api.*: under daemon — the db-platform version carries the daemon.* gateway helpers and daemon may execute them; transitional apibot — it can call api.* and read api.* views SELECT has_function_privilege('apibot','api.authorize_local(varchar,text,inet)','EXECUTE'); has_table_privilege('apibot','api.<view>','SELECT') for every view a package reads. A call that works as superuser and fails as apibot is a missing grant, not a Go defect
3 GatewayAPI module in the Apostol worker, module.GatewayAPI.enable = true, the module's network in allowed_cidr, audience service for the control plane GET /gateway/… upgrades with a client_credentials token of service-<domain>
4 The JWT algorithm and secret per audience as AuthServer signs them (conf/oauth2/*.json), not as db.audience stores them a wrong algorithm is 401 jwt: bad signature on every request
5 The go-platform submodule is pinned and the project module has replace github.com/apostoldevel/go-platform => ./platform git -C platform status --short empty; go build ./... in both modules

Without 1–4 the data plane cannot be exercised end to end. The control plane can be exercised without a gateway: cmd/gatewaystub plays it.


2. Inventory — the route registry

The registry is the only insurance of the schedule and the acceptance criterion of the switch. Build it before the first handler and keep it under version control (docs/wiki/go/v1-to-v2.md in CSMS is the reference shape).

  1. Enumerate dispatchers. InitAPI() of db-platform (api/init.sql) registers the platform's rest.<route> dispatchers; the project's InitAPI() in its configuration adds its own. Each dispatcher is rest.<route>(pPath, pPayload) with WHEN '/…' branches. Count the branches by script, then read the branches that call no api.* function by hand — they read a view through JsonbToFields.
  2. Find the owner of each api.* call. grep -rn 'FUNCTION api.<f>\|VIEW api.<f>' in db/sql/platform/** and db/sql/configuration/<project>/**. The Go package is chosen by the SQL module that owns the api.* function, not by the file where the path is dispatched (rest.user lives in api/rest.sql but calls admin/api.sql → package admin).
  3. Verify existence against a live database. pg_proc decides. Dead v1 routes exist (/user/profile called api.profile, which never existed); they are not migrated and are recorded as dead.
  4. Record the consumers. For every SPA and integration list the v1 calls it makes (grep for /api/v1/ in the client code; CSMS admin panel: 116 calls). The switch of consumers (§7) is driven by this list, not by the server side.
  5. Fix the registry with the owner. The agreed registry — routes in, routes out with a reason — is what "all routes migrated" means later. Everything added afterwards is a change of scope, recorded as such.

The registry row: v1 path · api.* function(s) · SQL module · Go package · v2 method and path · status.


3. Shape translation — one rule for every row

v1 (POST /api/v1/…, envelope, 200 always) v2
/<x>/list with search/filter/orderby/reclimit/recoffset/fields GET /api/v2/<xs>?filter[…]&sort=&fields=&page[limit]=&page[offset]= → {items, total, limit, offset}; lib/query translates filter/sort to the search/orderby jsonb of api.sql() — the operator language stays in the database, Go only renames; fields is applied in Go
/<x>/count GET /api/v2/<xs>/count, or total in the list answer — decide on the first package and keep it
/<x>/get {id} GET /api/v2/<xs>/{id} with a weak ETag; If-None-Match → 304; a null row in v1 is 404 in v2
/<x>/set without / with id POST /api/v2/<xs> (201, Location, Idempotency-Key) / PATCH /api/v2/<xs>/{id} (If-Match: 428 without, 412 when stale)
/<x>/delete {id} DELETE /api/v2/<xs>/{id} → 204; no api.delete_<x> → the verb is 405
/<x>/<y>/add, /<x>/<y>/delete (memberships) POST /api/v2/<xs>/{id}/<ys> / DELETE /api/v2/<xs>/{id}/<ys>/{yid}
/<x>/<verb> (lock, clear, copy, clone, …) POST /api/v2/<xs>/{id}/actions/<verb>
reading a view with fields GET /api/v2/<xs>?fields=
array in payload array in POST — all or nothing, one transaction
{error: {code, message}} problem+json: type = urn:apostol:error:ERR-GGG-NNN, status = GGG when GGG is a valid HTTP status, else 400; 409 for a duplicate key; title from the catalogue in Accept-Language
typed variants of one call (/registry/write/<type> × 5) one resource, the type in the body
several GETs for one object (/current/* × 7) one resource (GET /api/v2/me)
several POSTs for fields of one object (/session/set/* × 4) one PATCH (PATCH /api/v2/me)
two paths calling the same api.* (/user/* and /admin/user/*) one resource; who may do what is decided by the database, not by the path

Resource names: plural nouns, hyphenated when compound (event-log, state-types). Unknown JSON keys in a body are 400, as CheckJsonbKeys does in the database. Unknown filter/sort/fields/page parameters, a malformed query string, an unknown operator: 400, never "ignore the part we did not understand" — a filter that silently disappears turns a selection into "everything" (incident: 96 users deleted by a client script whose % was not escaped).


4. What is not migrated — and what replaces it

v1 Why Replacement
/sign/in, /sign/out, /authenticate, /su v2 does not authenticate: AuthServer issues the JWT (/oauth2/token), Go verifies the signature, the database trusts the session code in sub. A second sign-in path is a second door sign-in: /oauth2/token. Sign-out has no replacement yet: AuthServer has no /oauth2/revoke endpoint (its actions today are token, identifier, consent and the authorization-code flow), and the __Secure-* cookies are cleared only by AppServer on /api/v1/sign/out. Until AuthServer gains a revoke endpoint, /sign/out stays on v1 — and AppServer cannot be switched off. The platform sessions resource is read-only (GET /api/v2/sessions)
/sign/up not platform — a configuration function a project resource under a service token, when the consumer needs it
/authorize the first step of every v2 transaction, not a route lib/pgtx
/run multiplexer of v1 paths array in POST of one resource
/ping, /time the gateway's control plane and the Date header none as a route. A consumer that needs end-to-end liveness (an offline-capable client polling its server) uses a health resource of the Go process through the gateway — GET only, no session, sent without credentials (see § 10). The process's own /healthz is internal and does not go through the gateway
/observer/subscribe, /unsubscribe, listener writes subscriptions live in the WebSocket session; the key is the session code, which never travels in a v2 path or body WebSocketAPI; own subscriptions read-only at GET /api/v2/observer/listeners
/current/session the session code is the credential; it is not returned v2 knows it from sub
dead routes (function absent in pg_proc) v1 is not a reference for them recorded as dead in the registry

Add a row here for every route the project decides not to migrate. A route absent from both the "migrated" and the "not migrated" list is a defect of the registry.


5. Implementation — package per SQL module, one binary per project

The Go tree mirrors the SQL tree (Development-Strategy § Layout):

  1. Package = folder of the SQL module or entity, package name = its name (client, not clients), at the path of its rest.sql (entity/object/document/client/). Platform modules live in go-platform (admin/, workflow/, …); project entities live in the project's module (configuration/<project>/entity/object/document/<x>/).

  2. platform.Module is Name · Prefixes · Routes(mux) and nothing else. Install/migrate/embedded SQL belong to stage 2 of the strategy; do not add them ahead of the owner's word.

  3. Most packages write no handlers. rest.Resource{Prefix, GetFn, ListFn, CountFn} names the api.get_<x> / api.list_<x> / api.count_<x> family; rest.Writable adds api.set_<x> / api.delete_<x> with a typed body; Routes registers the verbs. The six template operations (list, count, get, set, delete + actions) are the same for every entity and are the target of the catalogue generator (GetRoutines/GetColumns), which emits the package into the entity's folder, not into a separate tree.

  4. Hand-written handlers for what is not a template: composite answers (notifications/changed calls api.get_<entity> per object), typed PUT, actions. Inside a handler the shape is always the same:

    err = m.doer.Do(r.Context(), platform.SessionOf(r), rest.ReqOf(r, 200, nil),
        func(ctx context.Context, tx pgx.Tx) error {
            return tx.QueryRow(ctx, "SELECT row_to_json(t) FROM api.get_client($1) t", id).Scan(&row)
        })

    Validate what can be validated before the transaction (rest.IDOf → 400 without the database); never open a second transaction for one request; never call a kernel function without the api. prefix — the pool role cannot see it (api.current_userid(), not current_userid()). The sample above is the transitional apibot path; on the daemon path the same api.* call goes through the daemon.* helper — the pool role sees schema daemon only.

  5. The manifest is modules() in cmd/<process>/main.go, in the order of \ir in the matching create.psql. What is not imported is not in the binary. The process name at /register is the binary's name (platform-service is the agreed generic name; CSMS used csms-api), not a package name.

  6. One process, one pool, one gateway socket, one image. Split by prefix only when load shows it: the same binary with another manifest.

  7. Prefixes must not overlap across processes (409 at /register); nested prefixes of the same process are collapsed by platform.Prefixes — the gateway routes by longest prefix.


6. Tests — parity is the acceptance criterion

Level What Where
Host 401 without a database, 404/405 + Allow as problem+json, canonical path, duplicate prefix refused at start go-platform module_test.go — not duplicated in packages
Package, unit manifest builds with modules(nil, nil) (constructors touch no database), body validation, query translation package _test.go
Package, integration every resource against a live db-platform under the deployment's role (daemon; apibot on the transitional path), session minted through an administrator login; GO_TEST_DB_PLATFORM_VERSION checked -tags integration -run Integration
Parity for every route of the registry the v2 answer equals the v1 answer on the same data: field set, values, order under a given sort, error codes; allowed differences are listed in a deviation list approved by the owner, not discovered at acceptance the parity tool of the project (CSMS: automated comparison of the old and new implementation, built at stage 2 of the contract)
Cross-layer the external harness (pytest in CSMS) calls /api/v2 through the gateway with real tokens; a red test there is a card for the Go layer, never edited by the Go layer tests/ of the project
Concurrency two concurrent requests under different users must not exchange sessions (session context is per transaction; RESET ALL on connection return) package integration test

Before every commit: go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1 -race in both modules (the project's ./... does not descend into the submodule). "It works" is accepted only with this output.


7. Switching consumers — page by page, v1 stays alive

  1. The server side of a resource is done when parity is green; nothing on the client changes at that moment.
  2. Consumers move one page (one screen, one integration) at a time, from the consumer inventory of § 2. Each move is a card of the consumer's layer with the list of v1 calls it retires.
  3. A v2 client in a SPA is a separate module (useApiV2 beside useApi), never a rewrite of the v1 client: both run in the same build until the last page moves.
  4. Destructive verbs (DELETE, actions that delete) get a confirmation in the UI before the page moves to v2 — v2 does what it is told; the safety net of a route-per-verb in rest.* is gone.
  5. WebSocket subscriptions stay as they are. A page that used /observer/subscribe over HTTP did so through WebSocketAPI anyway.
  6. Deployments: the Go process is a new container beside the existing ones; the gateway module is switched on per deployment (GATEWAY_ENABLE); brands that have no consumer on v2 keep it off. The image is built once and promoted through environments unchanged.

8. Retiring rest.*

Only when § 7 is complete for every consumer of the deployment:

  1. The consumer inventory shows zero v1 calls for the routes of a dispatcher.
  2. The API log (db.api_log, api.log_request writes v2 lines beside v1 lines in the same table) shows no v1 calls to those routes for an agreed period on the live deployment.
  3. The dispatcher is removed by a database patch (--patch, migration discipline: production databases are never rebuilt), its RegisterRoute line goes from InitAPI(), the wiki row moves to "retired".
  4. Never remove api.* functions in this step: they are the business logic and they are what v2 calls.

Retiring is per dispatcher, not all at once, and it is the last step, not a milestone of its own: a project may run both paths for as long as a consumer needs v1.


9. Done when

  • The registry has every route in exactly one of: migrated (parity green), not migrated (reason), dead.
  • go test in both modules green with -race; the integration run names the db-platform version it ran against; the cross-layer harness green on /api/v2 through the gateway.
  • Every consumer in the inventory either moved or has a card naming what holds it.
  • The database patch is exactly the gateway module plus the daemon.* helpers (transitional: plus grants to apibot); git diff of kernel and of api.* signatures is empty.
  • The deviation list is approved and attached to the registry.

10. Traps — every one of these was hit once

Trap Rule
Session context is per transaction, not per connection api.authorize[_local] and every api.* call of one request in one transaction; pool RESET ALL on release; pgbouncer in session mode when the database has no api.authorize_local
An aborted transaction cannot explain its own error daemon path: roll back to a savepoint and let the closing helper record the outcome in the same transaction; transitional path: ROLLBACK first, then api.parse_message on a separate connection, then problem+json
The gateway checks the __Secure-AT cookie before proxying an expired or malformed cookie is a 401 from the gateway that never reaches the Go process — a liveness probe sent with credentials reports "offline" for a live server; send it without credentials
There is no /oauth2/revoke in AuthServer yet sign-out needs AppServer (§ 4); plan the switch-off of AppServer after the revoke endpoint exists, not before
api.* views are not readable by apibot on older db-platform GRANT SELECT is the database's fix (db-platform 1.2.21 grants by default); a 500 on a plain GET is a grant defect
Some api.* used by v1 are not in the platform (api.whoami, api.signup are configuration) a platform package cannot depend on them; the project package can
rest.<x> is not module <x> package by the owner of the api.* function
DEFAULT parameters do not fire on an explicit NULL coalesce($n, (SELECT code FROM api.current_locale())) in the SQL, or omit the argument
Argument order differs between sibling functions (interface_member_add(member, interface) vs group_member_add(group, member)) read the signature in api.sql before calling; invent nothing
The session code is the whole credential never log Authorization, sub, or a path that carries a session code; mask it in Request.Path
r.URL.Query() drops a pair it cannot unescape and says nothing parse RawQuery with an error check → 400; the same for every "soft" ignore
Longest-prefix routing, first match wins in the gateway prefixes of different processes never overlap; nested prefixes of one process are registered once
draining is the module's to announce on SIGTERM /status draining → wait for in-flight → /unregister → close 1000; exit earlier and in-flight requests are 502
/api/v2/* over WebSocket refused by design (CALLERROR 404): HTTP for requests, WS for events
A Go package named error importers alias it (platformerror); the same for log, api
http.ServeMux.Handler returns a pattern for a redirect (/api/v2//x) check path canonicality before the mux, or a 307 text/html leaks and Allow lies
JWT_AUDIENCES algorithm the one AuthServer signs with (conf/oauth2/default.json), not the one stored on the audience row
Two instances with the same (module, instance) name the newer replaces the older (1001, gateway.log replaced); frequent replaced lines are a configuration error of the module