@ftisindia/sdk
v0.14.0
Published
Per-project client SDK generator for FTIS backends — emits self-contained Dart/Flutter and Angular/TypeScript SDKs from a backend's OpenAPI contract.
Readme
@ftisindia/sdk
ftis-sdk — per-project client SDK generator for FTIS backends. Reads a
backend's OpenAPI contract (including custom modules and the x-ftis vendor
extensions) and emits a self-contained client SDK for two targets:
- Dart/Flutter (default): the
ftis_coreruntime (vendored) plus typed models, per-tag API classes, an org-scoped facade, permission-key constants, and — on by default — the full clean-architecture stack (domain entities, pure request inputs, repository contracts + implementations, one use case per operation, and a sealedResult/Failurecore), so a BLoC app only hand-writes its presentation layer. - Angular/TypeScript (
--target angular), no FTIS npm packages required at runtime:- runtime (vendored as source): auth with single-flight refresh,
organisation context, RBAC, realtime, and the Angular provider, signal
services, origin-scoped auth interceptor, session and permission guards,
and
*ftisHasPermission; - contract layer: typed models (interfaces over the wire JSON,
optionality and nullability kept apart) and injectable API services
returning cold
Observables that compose withrxResource, plus typed permission keys and a static route → permission map; - forms: per request model, a validation spec, a typed Reactive
FormGroupfactory, and a Signal Forms factory (Angular 22); - state and routing: per-module facades (keyset-paged signal state,
errors normalized to
Failure) and permission-guarded route skeletons; - testing: deterministic model factories, MSW handlers, an in-memory
testing core (
provideFtisCoreTesting), and generated smoke specs.
- runtime (vendored as source): auth with single-flight refresh,
organisation context, RBAC, realtime, and the Angular provider, signal
services, origin-scoped auth interceptor, session and permission guards,
and
Both targets carry the client of the backend realtime plug-and-play module
(ftis.realtime.v1 over one WebSocket): sdk.realtime on Dart and
FtisRealtimeService on Angular — lazy, authenticated in-band from the
session, token rotation re-sent, backoff reconnect with re-subscription (see
the generated README's "Realtime" section). The vendored Dart runtime
therefore depends on package:http and package:web_socket_channel; the web
runtime has no dependencies.
# inside a scaffolded FTIS backend
npm run sdk:generate # → sdk/dart/<name>_sdk/
# against any backend
ftis-sdk generate --backend ./my-backend # Dart (exports the contract fresh)
ftis-sdk generate --backend ./my-backend --target angular # Angular → sdk/angular/<name>-sdk/
ftis-sdk generate --backend https://api.example.com --out ./my_sdk # fetches /docs-json (URL mode requires --out)Flutter apps consume the Dart output via a path or git dependency — no
pub.dev needed. Angular apps copy (or path-alias) the generated folder into
the app and register provideFrontendCore — see the generated README. See
the backend template's docs/SDK.md for usage; run ftis-sdk --help for
all flags (--target, --use-existing, --strict, --force, --name,
--out, --no-domain).
Clean-architecture layers
The generated tree mirrors the standard Flutter clean-architecture layout:
lib/src/generated/
├─ core/ Result, Failure, PagedResult, guardApiCall
├─ data/
│ ├─ models/ wire models (JSON) — *_model.dart
│ ├─ datasources/ typed API classes (remote data sources)
│ ├─ mappers/ model ↔ domain conversions
│ └─ repositories/ repository implementations
└─ domain/ entities/ inputs/ repositories/ usecases/<feature>/Backend schema names ending in Dto are emitted as *Model Dart classes
(SettingResponseDto → SettingResponseModel in
setting_response_model.dart); a schema whose *Model form is itself a
schema is a hard error (rename one DTO, e.g. @ApiSchema({ name })).
Names are append-stable. Every generated public name (API method,
repository, entity/input, use case) depends only on its own operation, tag,
or DTO — never on which siblings exist — so adding an endpoint, DTO, or
scope can never rename something a consumer already uses. Where two symbols
would derive the same name, generation fails with a message naming the
backend declaration to add: @FtisSdkName('listArchived') for an operation
(x-ftis-sdk-name) or @FtisSdkFeature('SettingsPublic') for the
repository of a tag+scope (x-ftis-sdk-feature, needed when one tag has
both root and org-scoped routes). The error also prints the names 0.12
promoted the colliding members to, so existing consumers can be kept
unchanged by declaring exactly those. The one additive change no stateless
rule can absorb: a request-only DTO that a new endpoint starts returning
moves from *Input to *Entity.
Everything is derived deterministically from the contract: response-reachable
models become pure *Entity classes (plus toEntity() mappers), request-only
models become pure *Input classes (plus toModel() mappers), each API class
becomes a repository contract + implementation, and every operation becomes a
<Feature><Action>UseCase returning Future<Result<T>> (ApiException and
transport errors normalize into a Failure value in one generated guard).
Domain files import nothing outside the domain layer and the pure core trio
(result/failure/paged_result) — enforced by tests.
Session-managed auth operations (login, signup, oauth/exchange,
refresh, logout) delegate to the vendored AuthEngine instead of the raw
API so token storage and silent refresh keep working; the engine surface the
templates rely on is pinned by test/engine-surface.test.mjs. Skipped
operations get no domain surface. Pass --no-domain to emit only
data/models/ + data/datasources/ (same nested layout, no core/domain/
mapper/repository layers).
How generation is driven
The backend declares SDK-relevant facts in its OpenAPI document
(x-ftis block, x-ftis-public, x-ftis-skip, x-ftis-sdk-name,
x-ftis-sdk-feature — see the template's
src/common/swagger/ftis-sdk.decorators.ts). The generator consumes those
first; documents without any x-ftis metadata fall back to heuristics (path
allowlists, a hard-coded skip ledger, and the legacy long-form promotion of
colliding method names) with a warning. --strict turns warnings into
failures.
Simple multipart uploads (binary file parts + primitive fields, JSON
response) generate typed upload methods, and single-media binary downloads
generate typed methods returning FtisBinaryResponse. Operations a typed
client still cannot represent are listed in the generated README and stay
reachable through the escape hatches: request (JSON), upload (multipart),
and requestBytes (binary responses). The skip reasons are
stream-or-202-union, browser-redirect, browser-link (a clickable
emailed-link target whose POST twin is the typed surface),
dynamic-format, non-json-response, and multipart.
Three behaviours worth knowing:
Generated names may not collide with the vendored runtime. Every generated model — and, on the Dart target, every hoisted enum — is checked against the runtime exports the barrel re-exports beside it (
ApiClient,AuthUser,Page, …). A collision warns, and warnings are fatal under--strict, because the Dart barrel would become ambiguous and TypeScript'sexport *would silently drop both conflicting names. Rename the backend DTO. This runs for both targets and regardless of--no-domain.The heuristic fallback can rot. The ledger in
src/ir.mjs(FALLBACK_SKIP_LEDGER,FALLBACK_PUBLIC_PATHS) hard-codes template routes as they existed before thex-ftisdecorators; it is only consulted for contracts with nox-ftisblock (typically URL mode against an old deployment). If those routes have since changed, fallback output can be wrong — prefer regenerating the backend so the contract carries metadata, and treat the fallback warning (fatal under--strict) as a prompt to do so.Pagination detection is shape-exact. A response schema maps to the runtime's
Page<T>only when it has exactly two properties,items(array) +nextCursor. Adding any field to a page envelope demotes it to a plain generated class — intentional, so enriched envelopes keep their fields — and the generator warns on that near-miss shape (fatal under--strict): move the extra fields elsewhere, or keep the plain class deliberately.
Development (monorepo)
The Dart runtime lives at mobile-starter/packages/ftis_core and is baked
into assets/ by scripts/sync-sdk-runtime.mjs at prepack/pretest (the
same lifecycle packages/cli uses for its template). Tests:
npm test (goldens under test/golden/; refresh deliberately with
node test/update-golden.mjs after contract or emitter changes).
