stoplight_elements 0.0.6
stoplight_elements: ^0.0.6 copied to clipboard
A Dart package for OpenAPI generation and Stoplight Elements page rendering.
stoplight_elements #
A Dart package for OpenAPI 3.0.3 generation with built-in Stoplight Elements interactive documentation rendering.
Describe endpoints and data models with annotations such as @ApiPath and @ApiModel, auto-scan them at runtime to produce OpenAPI JSON, and serve a browsable docs page via buildStoplightElementsHtml().
Features #
- Annotation-driven: paths, parameters, request bodies, responses, tags, and security schemes
- Runtime auto-scan:
OpenApiRegistry.instance.autoScan()discovers annotations viadart:mirrors - OpenAPI 3.0.3 output:
buildOpenApi()generates a complete spec - Type inference: omit
typeon field-level@ApiProperty; Dart types are inferred automatically - Nested types:
User,List<User>,Map<String, User>, etc. map to$ref/array/additionalProperties - Generic responses:
BaseResponse<HealthData>syntax expands schemas - Stoplight Elements HTML: generate a docs page in one call
Requirements #
- Dart SDK
^3.11.1 - A runtime that supports
dart:mirrors(not Flutter Web, AOT-compiled standalone binaries, or other reflection-less environments)
Installation #
Add to your pubspec.yaml:
dependencies:
stoplight_elements: ^0.0.6
Then run:
dart pub get
Import #
import 'package:stoplight_elements/stoplight_elements.dart';
The library entry point exports:
| Module | Contents |
|---|---|
annotations.dart |
All annotation classes |
generator.dart |
OpenApiRegistry |
config.dart |
OpenApiConfig |
stoplight_elements.dart |
buildStoplightElementsHtml() |
Quick Start #
1. Configure documentation metadata #
Call this at application startup (before autoScan / buildOpenApi):
OpenApiConfig.configure(
title: 'My API',
version: '1.0.0',
description: 'API documentation',
serverUrl: 'http://localhost:8000/api/v1/',
enableDebug: true,
defaultSchemes: ['http', 'https'],
);
Or assign fields directly:
OpenApiConfig.title = 'My API';
OpenApiConfig.serverUrl = 'http://localhost:8000/';
2. Define data models #
Option A: Field-level @ApiProperty (recommended)
Types can be omitted; they are inferred from Dart field types:
@ApiModel(description: 'Health check response')
class HealthData {
@ApiProperty(description: 'Service status', example: 'ok')
final String status;
@ApiProperty(description: 'Timestamp', example: '2023-01-01T00:00:00.000Z')
final String timestamp;
HealthData({required this.status, required this.timestamp});
}
Option B: Class-level properties map
@ApiModel(
description: 'Generic response wrapper',
properties: {
'code': ApiProperty(type: 'integer', description: 'Business code', example: 0),
'message': ApiProperty(type: 'string', description: 'Message'),
'data': ApiProperty(type: 'object', description: 'Payload'),
},
)
class BaseResponse {}
Field-level annotations merge with class-level
properties; field-level wins. Undeclared fields are still inferred from Dart types.
3. Define endpoints #
@ApiTag(name: 'Health', description: 'Health checks')
class HealthController {
@ApiPath(
path: '/health',
method: 'GET',
tags: ['Health'],
summary: 'Health check',
description: 'Check whether the service is running',
responses: {
200: ApiResponse(
code: 200,
description: 'Service is healthy',
schema: 'BaseResponse<HealthData>',
),
},
)
Future<Map<String, dynamic>> health() async {
return {'code': 0, 'message': 'ok', 'data': {'status': 'ok'}};
}
}
Controllers must have a parameterless default constructor, otherwise autoScan cannot instantiate them to scan method annotations.
4. Register and scan #
// Optional: register JWT Bearer or other security schemes
OpenApiRegistry.instance.registerSecurityScheme(
'authorization',
ApiSecurityScheme(
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Bearer authentication',
),
);
// Auto-scan (use libraryFilter to limit scope)
OpenApiRegistry.instance.autoScan(
libraryFilter: (uri) => uri.toString().startsWith('package:my_app/'),
);
autoScan runs only once per Isolate (guarded by an internal _isScanned flag).
5. Expose endpoints #
Recommended HTTP routes:
| Route | Purpose |
|---|---|
GET /openapi.json |
Returns OpenAPI JSON |
GET /docs |
Returns the Stoplight Elements docs page |
Vania framework example:
import 'package:vania/http/controller.dart';
import 'package:vania/http/response.dart';
import 'package:stoplight_elements/stoplight_elements.dart';
class OpenApiController extends Controller {
Future<Response> spec() async {
try {
final spec = OpenApiRegistry.instance.buildOpenApi();
return Response.json(spec);
} catch (e, st) {
return Response.json({
'error': 'Failed to generate OpenAPI spec',
'message': e.toString(),
'stackTrace': st.toString(),
});
}
}
Future<Response> docs() async {
final html = buildStoplightElementsHtml(
openapiUrl: '/openapi.json',
title: 'My API Documentation',
);
return Response.html(html);
}
}
Plain Dart shelf example:
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:stoplight_elements/stoplight_elements.dart';
Handler createHandler() {
return (Request request) {
if (request.url.path == 'openapi.json') {
return Response.ok(
jsonEncode(OpenApiRegistry.instance.buildOpenApi()),
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == 'docs') {
return Response.ok(
buildStoplightElementsHtml(openapiUrl: '/openapi.json'),
headers: {'content-type': 'text/html; charset=utf-8'},
);
}
return Response.notFound('Not Found');
};
}
Visit /docs after startup to view the interactive documentation.
Annotation Reference #
@ApiPath — Endpoint path #
@ApiPath(
path: '/users/{id}', // required: path
method: 'GET', // required: HTTP method
summary: 'Get user', // required: summary
description: 'Fetch user by ID', // optional: description
tags: ['Users'], // optional: tag groups
deprecated: false, // optional: deprecated flag
parameters: [...], // optional: path/query/header params
requestBody: ApiRequestBody(...), // optional: request body
responses: {200: ApiResponse(...)}, // optional: responses
security: ['authorization'], // optional: securitySchemes names
)
@ApiParameter — Request parameter #
const ApiParameter(
name: 'id',
location: 'path', // path | query | header | cookie
description: 'User ID',
required: true,
type: 'string', // OpenAPI type: string | integer | number | boolean
format: 'uuid', // optional: uuid, int64, date-time, etc.
example: '550e8400-e29b-41d4-a716-446655440000',
)
@ApiRequestBody — Request body #
const ApiRequestBody(
description: 'Create user request',
required: true,
schema: 'CreateUserRequest', // model name or generic, e.g. BaseResponse<User>
example: {'name': 'Alice'},
)
@ApiResponse — Response #
const ApiResponse(
code: 200,
description: 'Success',
schema: 'User', // model name
// schema: 'BaseResponse<User>', // generic expansion
example: {'id': '1', 'name': 'Alice'},
)
@ApiTag — Tag grouping #
Apply on a Controller class for the OpenAPI tags section:
@ApiTag(name: 'Users', description: 'User-related endpoints')
class UserController { ... }
If @ApiPath omits tags, the scanner uses the class-level @ApiTag name as the default.
@ApiModel — Data model #
@ApiModel(
description: 'User',
properties: { ... }, // optional, merged with field annotations
)
class User { ... }
@ApiProperty — Model field #
@ApiProperty(
type: 'string', // optional, inferred if omitted
description: 'Username',
required: true, // optional, inferred from nullability
format: 'email',
example: 'alice@example.com',
enumValues: ['a', 'b'], // enum values
ref: '#/components/schemas/User', // full $ref
schema: 'User', // shorthand → #/components/schemas/User
items: ApiProperty(...), // array item schema
additionalProperties: ApiProperty(...), // Map value schema
properties: { ... }, // inline object sub-fields
)
@ApiSecurityScheme — Security scheme #
Register via registerSecurityScheme, not as a class annotation:
// HTTP Bearer (JWT)
ApiSecurityScheme(
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT authentication',
)
// API Key
ApiSecurityScheme(
type: 'apiKey',
scheme: 'apiKey',
name: 'X-API-Key',
in_: 'header',
description: 'API Key authentication',
)
Reference in @ApiPath:
@ApiPath(
path: '/profile',
method: 'GET',
summary: 'Profile',
security: ['authorization'],
responses: { ... },
)
Type Inference Rules #
When scanning model fields, schemas are generated as follows:
| Dart type | OpenAPI output |
|---|---|
String |
{ "type": "string" } |
int |
{ "type": "integer", "format": "int64" } |
double |
{ "type": "number", "format": "double" } |
bool |
{ "type": "boolean" } |
DateTime |
{ "type": "string", "format": "date-time" } |
Enum subclass |
{ "type": "string" } |
@ApiModel class User |
{ "$ref": "#/components/schemas/User" } |
Plain class without @ApiModel |
inline { "type": "object", "properties": {...} } |
List<User> |
{ "type": "array", "items": { "$ref": "..." } } |
Map<String, User> |
{ "type": "object", "additionalProperties": { "$ref": "..." } } |
User? (nullable) |
same as above, but required: false |
dynamic + @ApiProperty(schema: 'User') |
{ "$ref": "#/components/schemas/User" } |
Nested example:
@ApiModel(description: 'User')
class UserDto {
@ApiProperty(description: 'ID', required: true)
final String id;
UserDto({required this.id});
}
@ApiModel(description: 'Paged result')
class UserPage {
final List<UserDto> items; // → array + items.$ref
final UserDto? current; // → $ref, required: false
UserPage({required this.items, this.current});
}
@ApiModel(description: 'Index map')
class UserMap {
final Map<String, UserDto> byId; // → additionalProperties.$ref
UserMap({required this.byId});
}
Generic Responses #
Response schemas support BaseSchema<ConcreteType> syntax. The generator replaces the data field in the base model with a $ref to the concrete type:
@ApiModel(
properties: {
'code': ApiProperty(type: 'integer'),
'message': ApiProperty(type: 'string'),
'data': ApiProperty(type: 'object'),
},
)
class BaseResponse {}
// Use in @ApiPath responses:
schema: 'BaseResponse<HealthData>'
This expands BaseResponse and points its data field to #/components/schemas/HealthData.
OpenApiRegistry API #
OpenApiRegistry is a singleton accessed via OpenApiRegistry.instance.
| Method | Description |
|---|---|
autoScan({libraryFilter}) |
Scan annotations in all libraries of the current Isolate; filter by URI |
scanController(Object controller) |
Manually scan a single Controller instance |
scanModel(Type type, {String? name}) |
Manually scan a single model class |
scanModelByName(String name, ClassMirror mirror) |
Internal: scan by class name |
registerPath(ApiPath path) |
Manually register a path |
registerModel(String name, ApiModel model) |
Manually register a schema |
registerSecurityScheme(String name, ApiSecurityScheme scheme) |
Register a security scheme |
registerTag(ApiTag tag) |
Register a tag |
buildOpenApi() |
Generate the full OpenAPI 3.0.3 JSON |
resetForTesting() |
Clear the registry (for tests) |
libraryFilter examples #
// Scan only your application package
OpenApiRegistry.instance.autoScan(
libraryFilter: (uri) => uri.toString().startsWith('package:my_app/'),
);
// Scan only a test file
OpenApiRegistry.instance.autoScan(
libraryFilter: (uri) => uri.toString().contains('my_test.dart'),
);
buildStoplightElementsHtml Parameters #
Generates a full HTML page embedding Stoplight Elements Web Components:
final html = buildStoplightElementsHtml(
openapiUrl: '/openapi.json', // required: OpenAPI JSON URL
title: 'API Documentation', // page title
stoplightElementsJsUrl: 'https://unpkg.com/@stoplight/elements/web-components.min.js',
stoplightElementsCssUrl: 'https://unpkg.com/@stoplight/elements/styles.min.css',
stoplightElementsFaviconUrl: 'https://fastapi.tiangolo.com/img/favicon.png',
apiDescriptionDocument: '', // inline OpenAPI doc (alternative to URL)
basePath: '', // API base path
hideInternal: false, // hide x-internal operations
hideTryIt: false, // hide Try It panel
tryItCorsProxy: '', // CORS proxy URL
tryItCredentialPolicy: StoplightTryItCredentialPolicyOptions.omit,
layout: StoplightLayoutOptions.sidebar, // sidebar | stacked
logo: '', // logo URL
router: StoplightRouterOptions.history, // history | hash | memory | static
);
Layout and router constants #
// Layout
StoplightLayoutOptions.sidebar // sidebar (default)
StoplightLayoutOptions.stacked // stacked
// Router mode
StoplightRouterOptions.history // History API (default)
StoplightRouterOptions.hash // hash routing
StoplightRouterOptions.memory // memory routing
StoplightRouterOptions.static_ // static routing
// Try It credential policy
StoplightTryItCredentialPolicyOptions.omit
StoplightTryItCredentialPolicyOptions.include
StoplightTryItCredentialPolicyOptions.sameOrigin
Cross-origin Try It #
If the API and docs page are on different origins, configure a CORS proxy:
buildStoplightElementsHtml(
openapiUrl: '/openapi.json',
tryItCorsProxy: 'https://cors.example.com/',
);
Full Integration Example (Vania) #
// ── models/health.dart ──
@ApiModel(description: 'Health check data')
class HealthData {
@ApiProperty(description: 'Status', example: 'ok')
final String status;
@ApiProperty(description: 'Service name', example: 'my_api')
final String service;
HealthData({required this.status, required this.service});
Map<String, dynamic> toJson() => {'status': status, 'service': service};
}
// ── controllers/health_controller.dart ──
@ApiTag(name: 'Health', description: 'Health checks')
class HealthController extends Controller {
@ApiPath(
path: '/health',
method: 'GET',
tags: ['Health'],
summary: 'Health check',
responses: {
200: ApiResponse(
code: 200,
description: 'OK',
schema: 'HealthData',
),
},
)
Future<Response> health() async {
return Response.json(
HealthData(status: 'ok', service: 'my_api').toJson(),
);
}
}
// ── providers/route_service_provider.dart ──
class RouteServiceProvider extends ServiceProvider {
@override
Future<void> register() async {
OpenApiConfig.configure(
title: 'My API',
version: '1.0.0',
serverUrl: 'http://localhost:8000/api/v1/',
);
OpenApiRegistry.instance.registerSecurityScheme(
'authorization',
ApiSecurityScheme(
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Bearer',
),
);
OpenApiRegistry.instance.autoScan(
libraryFilter: (uri) => uri.toString().startsWith('package:my_app/'),
);
// Register business routes and OpenAPI routes...
}
}
Notes #
-
dart:mirrorslimitation
This package relies on runtime reflection. It does not work on Flutter Web, AOT-compiled native binaries, or similar environments. Use it on the Dart VM (server-side). -
autoScanruns once
Repeated calls in the same Isolate are ignored. CallresetForTesting()in tests to reset. -
Controllers need a parameterless constructor
Scanning instantiates controllers vianewInstance. Named-only constructors are skipped. -
Use
libraryFilterfor performance
Without a filter, all loaded libraries are scanned. In production, limit to your application package path. -
Schema naming
Schema names default to the Dart class name (e.g.HealthData→#/components/schemas/HealthData). -
OpenAPI version
Output is fixed atopenapi: 3.0.3.
Development and Testing #
# Run tests
dart test
# Analyze code
dart analyze
Tests use resetForTesting() plus autoScan with libraryFilter to isolate scan scope. See test/nested_schema_test.dart.
Changelog #
See CHANGELOG.md.
License #
MIT — see LICENSE.