degenerate

degenerate

A fast, opinionated OpenAPI 3.x to Dart code generator.

Features · Quick Start · Usage · Middleware · State Management · Tested Specs

A Dart OpenAPI client generator that actually works on real-world specs. Zero analysis issues
on Stripe, GitHub, Kubernetes, and 9 others, with no manual fixups. Production-ready batteries included.


Features

  • Strives for full OpenAPI 3.0, 3.1, and 3.2 compatibility including allOf, oneOf, anyOf, discriminated unions, additionalProperties, nullable types, circular references, external $ref file resolution, and the OAS 3.2 query method + additionalOperations. File a bug if something doesn't work
  • Forward-compatible: unknown enum values preserve their raw string for round-trip fidelity; unknown union discriminators produce typed $Unknown variants
  • Lightweight unions: oneOf/anyOf schemas emit typedef aliases over generic OneOf containers with pattern matching support, avoiding heavy sealed class hierarchies
  • Typed streaming: SSE (text/event-stream) and JSONL (application/jsonl) responses with itemSchema return Stream<T> with typed deserialization
  • Response envelope unwrapping: --unwrap-fields=result returns the inner type directly instead of the full envelope, matching how Stainless generates Cloudflare/OpenAI SDKs
  • Zero analysis issues: generated code passes default dart analyze with no errors, warnings, or hints
  • Fast: generates ~14,000 files from the Cloudflare spec in ~6 seconds (AOT compiled)
  • Tag & path filtering: generate only the APIs you need with --tag and --path; unused types are automatically tree-shaken
  • Multi-format request/response: JSON, text, binary, multipart/form-data, and form-urlencoded bodies with media-type-aware serialization
  • Pluggable HTTP: bring your own HTTP client via degenerate_http (package:http) or degenerate_dio (package:dio), or implement the ApiClient interface
  • Cancel tokens & per-request options: cancel in-flight requests at the socket level, override timeout/headers per call
  • OkHttp-style middleware: single intercept(request, next) pattern with built-in retry, auth, and logging interceptors
  • Modular output: one file per model, small types inlined into their parent, barrel file for convenient imports

Quick Start

# Add to your pubspec.yaml
dart pub add dev:degenerate
# Generate a client from a spec
dart run degenerate -i petstore.yaml -n petstore

The generated code is placed in lib/petstore/ by default. Add the dependencies the CLI tells you to your pubspec.yaml, then use the client:

import 'package:degenerate_http/degenerate_http.dart';
import 'package:my_app/petstore/petstore.dart';

void main() async {
  final client = HttpApiClient(
    baseUrl: Uri.parse(PetstoreApi.defaultBaseUrl),
  );

  final sdk = PetstoreApi(
    ApiConfig(
      client: client,
      interceptors: [
        LoggingInterceptor(),
        RetryInterceptor(maxRetries: 2),
      ],
      timeout: Duration(seconds: 10),
    ),
  );

  final result = await sdk.pet.findPetsByStatus(
    status: FindPetsByStatusStatus.available,
  );

  switch (result) {
    case ApiSuccess(:final data):
      print('Found ${data.length} pets');
    case ApiError(:final statusCode, :final rawError):
      print('Error $statusCode: $rawError');
    case ApiParseException(:final response):
      // Server returned 2xx but body didn't match the spec.
      // Fall back to raw response for manual parsing.
      print('Bad response body: ${response.body}');
    case ApiException(:final exception):
      print('Network error: $exception');
  }

  await client.close();
}

See example/ for a full working example against the live Petstore API, and example_workspace/ for a Dart workspace setup.

Usage

dart run degenerate [options]

Options:
  -i, --input              Path to OpenAPI spec, or "-" for stdin (required)
  -o, --output             Base output directory (default: lib, or packages with --workspace)
  -n, --name               Package name (default: api_client). Appended to -o to form the output path.
  -t, --tag                Only include APIs matching these tags (repeatable)
  -p, --path               Only include operations under these path prefixes (repeatable)
      --include-deprecated  Include deprecated operations
      --clean              Remove output directory before generating
      --workspace          Generate a standalone package with pubspec.yaml (for Dart workspaces)
  -v, --verbose            Print IR and diagnostics
      --dry-run            Parse and validate without writing files
      --unwrap-fields      Unwrap response envelopes by extracting named fields (repeatable)
  -h, --help               Show help
      --version            Print the tool version

Output Modes

Default mode generates code directly into your project's lib/ directory:

dart run degenerate -i petstore.yaml -n petstore
# Output: lib/petstore/

After generation, the CLI prints the dependencies you need to add to your pubspec.yaml.

Workspace mode (--workspace) generates a standalone Dart package with its own pubspec.yaml:

dart run degenerate -i petstore.yaml -n petstore --workspace
# Output: packages/petstore/

The CLI prints instructions for adding the package to your workspace and dependencies.

Code Formatting

Generated code is not formatted. Run dart format on the output to apply your project's formatting preferences:

dart run degenerate -i petstore.yaml -n petstore
dart format lib/petstore/

Examples

# Generate into a custom directory
dart run degenerate -i petstore.yaml -o clients -n petstore
# Output: clients/petstore/

# Only generate DNS-related APIs from a large spec
dart run degenerate -i cloudflare.yaml -t dns

# Multiple tags
dart run degenerate -i cloudflare.yaml -t dns -t workers

# Filter by path prefix
dart run degenerate -i cloudflare.yaml -p /zones

# Verbose output
dart run degenerate -i kubernetes-api.json -n k8s --verbose

# Clean output directory before generating (removes stale files)
dart run degenerate -i spec.yaml --clean

# Dry run to check for issues without writing files
dart run degenerate -i spec.yaml --dry-run

# Pipe a spec from a URL
curl -s https://petstore3.swagger.io/api/v3/openapi.json | dart run degenerate -i - -n petstore

# Workspace mode with custom output base
dart run degenerate -i spec.yaml -o my_packages --workspace -n my_api
# Output: my_packages/my_api/

# Unwrap Cloudflare-style response envelopes
# Methods return T directly instead of {success, errors, messages, result: T}
dart run degenerate -i cloudflare.yaml --unwrap-fields=result

Tag matching is case-insensitive and ignores spaces, underscores, and hyphens. When tags or paths are specified, unused types are automatically tree-shaken from the output.

Packages

The generator itself is a command-line tool (desktop only), but the generated code and runtime packages work on all Dart and Flutter platforms including iOS, Android, web, and desktop.

The runtime is split into separate packages so generated code has no opinion on which HTTP client you use:

Package Purpose Dependencies
degenerate_runtime Core interfaces (ApiClient, ApiConfig, ApiResult), middleware chain, built-in interceptors None
degenerate_http HttpApiClient adapter using package:http http, degenerate_runtime
degenerate_dio DioApiClient adapter using package:dio dio, degenerate_runtime

The adapter packages re-export degenerate_runtime, so you only need a single import:

// This gives you HttpApiClient + all runtime types (ApiConfig, ApiResult, interceptors, etc.)
import 'package:degenerate_http/degenerate_http.dart';

Using Dio

import 'package:degenerate_dio/degenerate_dio.dart';
import 'package:dio/dio.dart';
import 'package:my_app/petstore/petstore.dart';

final dio = Dio()
  // For granular timeout control, configure Dio directly
  // and leave ApiConfig.timeout null:
  ..options.connectTimeout = Duration(seconds: 5)
  ..options.receiveTimeout = Duration(seconds: 30);

final sdk = PetstoreApi(ApiConfig(
  client: DioApiClient(baseUrl: Uri.parse(PetstoreApi.defaultBaseUrl), inner: dio),
  // timeout: Duration(seconds: 10), // or use a single overall deadline here
));

Use the Dio instance for low-level settings like proxy configuration, custom adapters, and granular timeouts. Configure default headers, interceptors, and cancellation through ApiConfig.

Generated Output

Default mode (lib/<name>/):

<name>.dart              Barrel file (exports everything)
models/
  pet.dart               Data classes with fromJson/toJson/copyWith/==/hashCode
  pet_status.dart        Enum-like class with unknown value preservation
  user_id.dart           Extension type for branded primitives
  pet_or_error.dart      OneOf typedef for untagged unions
  shape.dart             Sealed class for discriminated unions
apis/
  pets_api.dart          API client class with typed methods
client/
  <name>_api.dart        Root SDK facade with lazy API group accessors

Workspace mode (packages/<name>/):

lib/
  <name>.dart            Barrel file
  models/                (same as above)
  apis/
  client/
pubspec.yaml             Generated with correct dependencies

Small types (extension types and enums) referenced by a single parent are automatically inlined into the parent's file to reduce file count.

Data Classes

Each schema with properties generates a final class with:

  • const constructor with named parameters
  • factory fromJson(Map<String, dynamic>) for deserialization
  • toJson() returning Map<String, dynamic>
  • copyWith() with nullable callbacks for optional fields
  • Value equality (== and hashCode)
  • toString() with all fields
  • additionalProperties overflow Map<String, T> when the schema allows extra keys

Enums

String, integer, and number enums generate a final class with static const instances. Unknown server values are preserved via the raw value field, enabling round-trip fidelity:

final class PetStatus {
  static const available = PetStatus._('available');
  static const pending = PetStatus._('pending');
  static const sold = PetStatus._('sold');
  static const values = [available, pending, sold];

  final String value;
  const PetStatus._(this.value);

  factory PetStatus.fromJson(String json) => switch (json) {
    'available' => available,
    'pending' => pending,
    'sold' => sold,
    _ => PetStatus._(json),  // Preserves unknown values
  };

  String toJson() => value;
  bool get isUnknown => !values.contains(this);
}

Extension Types (Branded Primitives)

Named primitive schemas (e.g., UserId as a string with format: uuid) generate zero-cost Dart extension types:

extension type const UserId(String value) {
  factory UserId.fromJson(String json) => UserId(json);
  String toJson() => value;
}

This provides compile-time type safety without runtime overhead. You can't accidentally pass a String where a UserId is expected. Types with formats like date-time, uri, and int32 automatically parse/serialize:

extension type Timestamp(DateTime value) {
  factory Timestamp.fromJson(String json) => Timestamp(DateTime.parse(json));
  String toJson() => value.toIso8601String();
}

Untagged Unions (oneOf / anyOf)

oneOf and anyOf schemas with 2-9 variants generate lightweight type aliases using generic OneOf containers from degenerate_runtime:

// Generated: typedef Notification = OneOf2<EmailDetails, SmsDetails>;

// Pattern match on the union value
void handleNotification(Notification notification) {
  switch (notification.value) {
    case EmailDetails email => print('Email to ${email.to}'),
    case SmsDetails sms => print('SMS to ${sms.phone}'),
  }
}

// Create a union value to pass to an API call
final notification = Notification.from(
  EmailDetails(to: 'user@example.com', subject: 'Hello'),
);
await sdk.notifications.send(body: notification);

Since Notification is a type alias, .from() validates at runtime that the value matches one of the expected types. Pattern matching on .value gives you exhaustive type checking.

Discriminated Unions

oneOf with discriminator generates a sealed class hierarchy with an unknown variant:

sealed class Shape {
  String get type;
  factory Shape.fromJson(Map<String, dynamic> json) => switch (json['type']) {
    'circle' => ShapeCircle.fromJson(json),
    'square' => ShapeSquare.fromJson(json),
    _ => Shape$Unknown(json),  // Forward-compatible
  };
  bool get isUnknown => this is Shape$Unknown;
}

API Client

Each tag in the spec generates an API class. The root SDK facade provides lazy accessors for each group:

final sdk = PetstoreApi(ApiConfig(client: myHttpClient));

// Access API groups via the SDK facade
final result = await sdk.pet.getPetById(petId: 1);
switch (result) {
  case ApiSuccess(:final data):
    print('Found: ${data.name}');
  case ApiError(:final statusCode):
    print('Error: $statusCode');
  case ApiException(:final exception):
    print('Network error: $exception');
}

Middleware

Interceptors use an OkHttp-style chain where each interceptor receives the request and a next handler. This enables retry, auth refresh, logging, and any custom logic:

abstract interface class Interceptor {
  Future<ApiResponse> intercept(ApiRequest request, Handler next);
}

Built-in Interceptors

RetryInterceptor: exponential backoff on 429 and 5xx:

RetryInterceptor(
  maxRetries: 3,
  initialDelay: Duration(seconds: 1),
  retryWhen: (response) => response.statusCode >= 500,  // custom condition
)

AuthInterceptor: adds auth headers with optional token refresh on 401:

AuthInterceptor(
  getToken: () async => myTokenStore.accessToken,
  refreshToken: () async {
    await myTokenStore.refresh();
    return myTokenStore.accessToken;
  },
  scheme: 'Bearer',  // default
)

LoggingInterceptor: logs requests and responses:

LoggingInterceptor()  // prints to stdout by default
LoggingInterceptor(logger: myLogger.info)  // custom logger

Custom Interceptors

class TimingInterceptor implements Interceptor {
  @override
  Future<ApiResponse> intercept(ApiRequest request, Handler next) async {
    final sw = Stopwatch()..start();
    final response = await next(request);
    print('${request.method} ${request.path} took ${sw.elapsedMilliseconds}ms');
    return response;
  }
}

Interceptors execute in order. The first interceptor in the list is the outermost wrapper:

ApiConfig(
  client: client,
  interceptors: [
    LoggingInterceptor(),    // 1. logs the request
    AuthInterceptor(...),    // 2. adds auth header
    RetryInterceptor(...),   // 3. retries on failure (retries include auth)
  ],
)

Cancel Tokens & Per-Request Options

Every generated method accepts an optional RequestOptions for per-request overrides:

// Cancel a request (closes the socket immediately in both http and dio adapters)
final token = CancelToken();
final result = await sdk.pet.listPets(
  options: RequestOptions(cancelToken: token),
);
// From another isolate, timer, or UI callback:
token.cancel();

// Per-request timeout (overrides ApiConfig.timeout)
final result = await sdk.pet.getPetById(
  petId: 1,
  options: RequestOptions(timeout: Duration(seconds: 5)),
);

// Extra headers for a single request
final result = await sdk.pet.listPets(
  options: RequestOptions(extraHeaders: {'X-Request-Id': 'abc-123'}),
);

Cancel tokens work at the socket level. Both adapters abort the underlying connection rather than just abandoning the future. A cancelled request surfaces as ApiException(CancelledException()). A timed-out request surfaces as ApiException(TimeoutException(...)).

State Management

Generated models are designed to work well with Riverpod, Bloc, and other Flutter state management solutions: immutable final class with const constructors, ==/hashCode for change detection, copyWith for state updates, and toJson/fromJson for cache persistence.

dataOrThrow

Every ApiResult has a dataOrThrow getter that returns the success data or throws. This bridges the gap between ApiResult and AsyncValue:

@riverpod
Future<List<Pet>> pets(Ref ref) async {
  final result = await ref.watch(sdkProvider).pet.listPets();
  return result.dataOrThrow;
}

On ApiError, it throws the error itself (which implements Exception and carries statusCode and typed error). On ApiException, it rethrows the original exception with its stack trace preserved.

Cancellation with Riverpod

Wire a CancelToken to ref.onDispose so in-flight requests are cancelled when the provider is disposed (e.g. user navigates away):

@riverpod
Future<List<Pet>> pets(Ref ref) async {
  final token = CancelToken();
  ref.onDispose(token.cancel);

  final result = await ref.watch(sdkProvider).pet.listPets(
    options: RequestOptions(cancelToken: token),
  );
  return result.dataOrThrow;
}

Error handling

ApiError implements Exception, so Riverpod surfaces it through AsyncValue.error. You can inspect the status code and typed error in your UI:

ref.watch(petsProvider).when(
  data: (pets) => PetList(pets),
  loading: () => CircularProgressIndicator(),
  error: (error, stack) => switch (error) {
    ApiError(:final statusCode) => Text('Server error: $statusCode'),
    _ => Text('Something went wrong'),
  },
);

Granular rebuilds

Generated models implement == and hashCode, so Riverpod's select() works out of the box to prevent unnecessary widget rebuilds:

// Only rebuilds when the pet's name changes
final name = ref.watch(petProvider.select((pet) => pet.name));

Tested Specs

Spec Status
Petstore (3.0) 0 issues
Twilio 0 issues
Shopify 0 issues
Kubernetes (JSON) 0 issues
Totem Mobile (3.1) 0 issues
OpenAI 0 issues
GitHub REST 3.1 0 issues
Cloudflare 0 issues
Stripe 0 issues
Unhinged (3.2 edge cases) 0 issues

Limitations

  • Swagger 2.0 is not supported; only OpenAPI 3.0+ (3.0, 3.1, and 3.2 features are supported)
  • Remote $ref URLs (e.g., $ref: 'https://...') are not resolved; only local filesystem refs are supported

License

MIT

Libraries