Repository navigation
API v1 to v2 Migration
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.
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.
| # | 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.
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).
-
Enumerate dispatchers.
InitAPI()of db-platform (api/init.sql) registers the platform'srest.<route>dispatchers; the project'sInitAPI()in its configuration adds its own. Each dispatcher isrest.<route>(pPath, pPayload)withWHEN '/…'branches. Count the branches by script, then read the branches that call noapi.*function by hand — they read a view throughJsonbToFields. -
Find the owner of each
api.*call.grep -rn 'FUNCTION api.<f>\|VIEW api.<f>'indb/sql/platform/**anddb/sql/configuration/<project>/**. The Go package is chosen by the SQL module that owns theapi.*function, not by the file where the path is dispatched (rest.userlives inapi/rest.sqlbut callsadmin/api.sql→ packageadmin). -
Verify existence against a live database.
pg_procdecides. Dead v1 routes exist (/user/profilecalledapi.profile, which never existed); they are not migrated and are recorded as dead. -
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. - 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.
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).
| 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.
The Go tree mirrors the SQL tree (Development-Strategy § Layout):
-
Package = folder of the SQL module or entity, package name = its name (
client, notclients), at the path of itsrest.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>/). -
platform.ModuleisName · 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. -
Most packages write no handlers.
rest.Resource{Prefix, GetFn, ListFn, CountFn}names theapi.get_<x>/api.list_<x>/api.count_<x>family;rest.Writableaddsapi.set_<x>/api.delete_<x>with a typed body;Routesregisters 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. -
Hand-written handlers for what is not a template: composite answers (
notifications/changedcallsapi.get_<entity>per object), typedPUT, 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→400without the database); never open a second transaction for one request; never call akernelfunction without theapi.prefix — the pool role cannot see it (api.current_userid(), notcurrent_userid()). The sample above is the transitionalapibotpath; on thedaemonpath the sameapi.*call goes through thedaemon.*helper — the pool role sees schemadaemononly. -
The manifest is
modules()incmd/<process>/main.go, in the order of\irin the matchingcreate.psql. What is not imported is not in the binary. The process name at/registeris the binary's name (platform-serviceis the agreed generic name; CSMS usedcsms-api), not a package name. -
One process, one pool, one gateway socket, one image. Split by prefix only when load shows it: the same binary with another manifest.
-
Prefixes must not overlap across processes (
409at/register); nested prefixes of the same process are collapsed byplatform.Prefixes— the gateway routes by longest prefix.
| 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.
- The server side of a resource is done when parity is green; nothing on the client changes at that moment.
- 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.
- A v2 client in a SPA is a separate module (
useApiV2besideuseApi), never a rewrite of the v1 client: both run in the same build until the last page moves. - 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 inrest.*is gone. - WebSocket subscriptions stay as they are. A page that used
/observer/subscribeover HTTP did so throughWebSocketAPIanyway. - 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.
Only when § 7 is complete for every consumer of the deployment:
- The consumer inventory shows zero v1 calls for the routes of a dispatcher.
- The API log (
db.api_log,api.log_requestwrites v2 lines beside v1 lines in the same table) shows no v1 calls to those routes for an agreed period on the live deployment. - The dispatcher is removed by a database patch (
--patch, migration discipline: production databases are never rebuilt), itsRegisterRouteline goes fromInitAPI(), the wiki row moves to "retired". - 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.
- The registry has every route in exactly one of: migrated (parity green), not migrated (reason), dead.
-
go testin both modules green with-race; the integration run names the db-platform version it ran against; the cross-layer harness green on/api/v2through the gateway. - Every consumer in the inventory either moved or has a card naming what holds it.
- The database patch is exactly the
gatewaymodule plus thedaemon.*helpers (transitional: plus grants toapibot);git diffofkerneland ofapi.*signatures is empty. - The deviation list is approved and attached to the registry.
| 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 |