OpenAPI & Flutter codegen
Single API serves both web (TS, shared types via package) and mobile (Dart, regen client from OpenAPI). Contract-first across the stack.
Spec generation
@hono/zod-openapi turns every route's Zod schemas into an OpenAPI 3.1 document automatically. No hand-written YAML.
// apps/api/src/index.ts (already in scaffold)
app.doc("/openapi.json", {
openapi: "3.1.0",
info: { title: "Spacehub API", version: "0.0.0" },
servers: [{ url: "/", description: "current" }],
});
app.get("/docs", apiReference({ spec: { url: "/openapi.json" }, theme: "deepSpace" }));
Scalar UI at /docs for human readers. /openapi.json for codegen.
Web ↔ API type-safety (no codegen needed)
Since both are TypeScript, share Zod schemas through a workspace package:
// packages/contracts/src/property.ts
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { properties } from "@spacehub/db";
export const PropertySchema = createSelectSchema(properties);
export const CreatePropertySchema = createInsertSchema(properties).omit({ id: true, createdAt: true, ownerId: true });
export type Property = z.infer<typeof PropertySchema>;
export type CreateProperty = z.infer<typeof CreatePropertySchema>;
API uses these schemas in route definitions; web imports the same types. Single source of truth.
Hono hc RPC client
Server Components can call the API typed via Hono's hc:
// apps/web/src/lib/api.ts
import { hc } from "hono/client";
import type { AppType } from "@spacehub/api/types"; // export type AppType = typeof app
import "server-only"; // never ship to browser bundle
export const api = hc<AppType>(process.env.API_INTERNAL_URL!);
// In a Server Component:
const res = await api.v2.properties.$get({ query: { limit: "20" } });
const { items } = await res.json(); // typed!
Flutter codegen — swagger_parser
Dart-native, generates Dio + Retrofit + Freezed 3.x models + json_serializable. Pin to API release tag.
Audit verified (May 26): swagger_parser is real, healthy, actively maintained — current version is v1.43.1 (~34 days ago), not 0.13.x as originally listed. Pub.dev: 120 likes, 160 pub points, 18.1k weekly downloads. MIT, verified publisher.
Known limitations in tracker (work around per endpoint as needed):
allOf+required edge cases (#440), enum casing (#458), nested multipart (#444), circular schema refs (#423).
Plan B: hand-rolled retrofit + freezed (no spec generator) — 427k weekly downloads, fully ergonomic.
Don't use: openapi_generator (17 months stale), swagger_dart_code_generator (locks to Chopper, no Dio/Freezed), raw openapi-generator-cli (JVM in build pipeline).
# mobile repo: pubspec.yaml
dev_dependencies:
swagger_parser: ^1.43.0
build_runner: ^2.4.0
freezed: ^3.2.0
json_serializable: ^6.7.0
retrofit_generator: ^10.2.0
dependencies:
dio: ^5.9.0
retrofit: ^4.9.0
freezed_annotation: ^3.1.0
json_annotation: ^4.8.0
# mobile repo: build.yaml
targets:
$default:
builders:
swagger_parser:
options:
schema_path: openapi.json # downloaded from API release
output_directory: lib/api
language: dart
json_serializer: freezed
client_postfix: Client
enums_to_json: true
freezed3: true
# regen
dart run swagger_parser
dart run build_runner build --delete-conflicting-outputs
Mobile-friendly API rules (baked in from day one)
- Cursor pagination on every list endpoint — never offset.
- Sparse fieldsets via
?fields=on heavy resources (save bandwidth on 3G). - ETag +
If-None-Matchon GETs — cheap refresh. - Consistent error envelope:
{ error: { code, message, details? } }— Flutter maps to single exception class. Idempotency-Keyheader on POSTs that create money-affecting state.- Versioned URL
/v2/...— easier for Flutter dev (visible in Charles/Proxyman). - SSE for streams — chatbot, real-time updates. Flutter handles via
eventsource. Easier than WS. - Push registration:
POST /v2/devices { fcm_token, platform, deviceLabel? }.
Auth flow Flutter-side
// pseudo-Dart
final dio = Dio();
final storage = FlutterSecureStorage();
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await storage.read(key: 'sessionToken');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (err, handler) async {
if (err.response?.statusCode == 401) {
final refreshed = await _refreshSingleFlight();
if (refreshed) {
return handler.resolve(await dio.fetch(err.requestOptions));
}
}
handler.next(err);
},
));
Single-flight refresh avoids stampeding the refresh endpoint on parallel 401s.
CI workflow
- API repo: tag release (
v2.1.0) → GitHub Action runspnpm --filter api build, fetcheshttp://localhost:3001/openapi.json, uploads as release asset. - Mobile repo: scheduled GitHub Action
regen-api-clientpolls latest API release. If newer than pinned: downloadsopenapi.json, runsdart run swagger_parser+ build_runner, opens PR titled "Regenerate API client for API v2.1.0". - Mobile dev reviews PR, merges.
Build steps
- Already done in scaffold: OpenAPI doc emitted, Scalar UI mounted.
- Create
packages/contractsfor shared Zod schemas. - Export
AppTypefromapps/api/src/index.tsforhcclient. - Document mobile-friendly conventions in
docs/api-conventions.md. - Add GitHub Action: API release → publish
openapi.json. - Coordinate with Flutter team: pubspec deps, build.yaml, regen workflow.
- Build mobile-friendly helpers in
packages/api: cursor pagination utility, ETag middleware, idempotency-key middleware.
Open questions
- Mobile version pinning: hard-pin API version in mobile app or accept latest? Recommend hard-pin with grace period (API supports last 2 minor versions).
- OpenAPI distribution: GitHub release vs npm-published JSON vs git submodule? GitHub release simplest.
- SSE in Flutter: native
eventsourcepackage or roll a Dio interceptor? Useeventsourcefor simplicity. - API breaking change policy: bump major
/v3path, dual-serve for N months?