degenerate 0.1.3 copy "degenerate: ^0.1.3" to clipboard
degenerate: ^0.1.3 copied to clipboard

OpenAPI to Dart client code generator.

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 and 3.1 compatibility including allOf, oneOf, anyOf, discriminated unions, nullable types, circular references, and external $ref file resolution. 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
  • Zero analysis issues: generated code passes default dart analyze with no errors, warnings, or hints
  • Fast: generates ~12,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
dev_dependencies:
  degenerate: ^0.1.0
# Generate a client from a spec
dart run degenerate -i petstore.yaml -o lib/src/generated -n petstore

Then use the generated client:

import 'package:degenerate_http/degenerate_http.dart';
import 'package: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.

Usage #

dart run degenerate [options]

Options:
  -i, --input              Path to OpenAPI spec (required)
  -o, --output             Output directory (default: lib/src/generated)
  -n, --name               Package name (default: inferred from spec title)
  -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          Add resolution: workspace to generated pubspec (for monorepos)
  -v, --verbose            Print IR and diagnostics
      --dry-run            Parse and validate without writing files
  -h, --help               Show help
      --version            Print the tool version

Examples #

# Generate from a YAML spec
dart run degenerate -i petstore.yaml -o lib/src/api -n petstore

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

# Multiple tags
dart run degenerate -i cloudflare.yaml -o lib/src/api -t dns -t workers

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

# Generate from a JSON spec with verbose output
dart run degenerate -i kubernetes-api.json -o lib/src/k8s --verbose

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

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

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 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: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 #

lib/
  <package_name>.dart          Barrel file (exports everything)
  src/
    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/
      <package_name>_api.dart  Root SDK facade with lazy API group accessors
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

Enums #

String 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

Limitations #

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

License #

MIT

3
likes
0
points
565
downloads

Publisher

verified publisherblopker.com

Weekly Downloads

OpenAPI to Dart client code generator.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

args, code_builder, collection, path, yaml

More

Packages that depend on degenerate