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.

Platform

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)

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

  1. API repo: tag release (v2.1.0) → GitHub Action runs pnpm --filter api build, fetches http://localhost:3001/openapi.json, uploads as release asset.
  2. Mobile repo: scheduled GitHub Action regen-api-client polls latest API release. If newer than pinned: downloads openapi.json, runs dart run swagger_parser + build_runner, opens PR titled "Regenerate API client for API v2.1.0".
  3. Mobile dev reviews PR, merges.

Build steps

  1. Already done in scaffold: OpenAPI doc emitted, Scalar UI mounted.
  2. Create packages/contracts for shared Zod schemas.
  3. Export AppType from apps/api/src/index.ts for hc client.
  4. Document mobile-friendly conventions in docs/api-conventions.md.
  5. Add GitHub Action: API release → publish openapi.json.
  6. Coordinate with Flutter team: pubspec deps, build.yaml, regen workflow.
  7. Build mobile-friendly helpers in packages/api: cursor pagination utility, ETag middleware, idempotency-key middleware.

Open questions

Sources