API docs with OpenAPI
Your API gets docs for free. Turn on OpenAPI for the blog API, open the docs page, and make the docs better with a few comments and annotations.
What you need
- The blog API from the tutorial, or any Dox app.
build_runneranddox_builder. A new app has both.
You do not add a package. dox_core already has dox_openapi in it.
1. Write the config
dox_builder already writes lib/openapi.g.dart when you run build_runner. It holds openApiRegistry: what the builder found in your routes, controllers, DTOs and models.
Pass it to an OpenApiConfig:
import 'package:dox_core/dox_core.dart';
import 'package:blog/openapi.g.dart';
OpenApiConfig openApi = OpenApiConfig(
info: Info(title: 'Blog API', version: '1.0.0'),
registry: openApiRegistry,
);
Add it to AppConfig:
import 'package:blog/config/openapi.dart';
AppConfig appConfig = AppConfig(
// ...
/// OpenAPI docs (config/openapi.dart)
openApi: openApi,
);
2. Build and start
.env has APP_ENV=development, so the docs routes are on.
3. Open the docs page
Open http://localhost:3000/docs in a browser. It is a Scalar page.
It lists every route, with "Try it" buttons. It loads Scalar from the jsDelivr CDN and reads /openapi.json.
The app now has three new routes:
| Route | What it gives |
|---|---|
GET /docs |
the Scalar docs page (HTML) |
GET /openapi.json |
the OpenAPI 3.1 document as JSON |
GET /openapi.yaml |
the same document as YAML |
4. Read the JSON
This is the real output for the blog API, shortened. The top level:
{
"openapi": "3.1.0",
"info": {"title": "Blog API", "version": "1.0.0"},
"paths": {
"/ping": {},
"/api/ping": {},
"/api/posts": {},
"/api/posts/{id}": {},
"/api/auth/register": {},
"/api/auth/login": {},
"/api/auth/user": {}
},
"components": {
"schemas": {"Post": {}, "SavePost": {}, "RegisterUser": {}, "LoginUser": {}, "User": {}, "ValidationError": {}},
"securitySchemes": {
"bearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
}
}
}
POST /api/posts, as the builder found it. You wrote no docs code for it:
{
"tags": ["Post"],
"summary": "POST /api/posts",
"operationId": "PostController.store",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SavePost"}}}
},
"responses": {
"200": {"description": "Successful response"},
"401": {"description": "Not logged in"},
"422": {
"description": "Validation failed",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ValidationError"}}}
},
"500": {"description": "Server error"}
},
"security": [{"bearerAuth": []}]
}
Where each part comes from:
| Part | Comes from |
|---|---|
tags: ["Post"] |
the class name PostController, without Controller |
summary |
the first line of the /// comment: /// POST /api/posts |
requestBody |
the argument SavePost body of store |
401 and bearerAuth |
AuthMiddleware on the route |
422 |
the route has a request class |
200 with no body |
store returns DoxResponse, so the builder can not see the body. Step 6 fixes this |
5. See how a DTO becomes a schema
This is the SavePost DTO of the tutorial:
@Dto()
class SavePost {
@Trim()
@Length(min: 3, max: 100)
final String title;
@Trim()
@MinLength(10)
final String body;
const SavePost({required this.title, required this.body});
static const DtoSchema<SavePost> schema = $SavePostDto;
}
And this is its schema in /openapi.json:
"SavePost": {
"type": "object",
"title": "SavePost",
"properties": {
"title": {"type": "string", "minLength": 3, "maxLength": 100},
"body": {"type": "string", "minLength": 10}
},
"required": ["title", "body"]
}
| In the DTO | In the schema |
|---|---|
String title (not nullable) |
"type": "string" and title in required |
@Length(min: 3, max: 100) |
"minLength": 3, "maxLength": 100 |
@MinLength(10) |
"minLength": 10 |
@Email() (in RegisterUser) |
"format": "email" |
@Trim(), @Lowercase(), @Unique(...) |
nothing. They change or check the value on the server only |
A model becomes a schema too. Post has id, the @Column fields title and body, and created_at and updated_at.
Keys in hidden are left out. That is why User has no password.
The docs and the validation come from the same DTO. When you change a rule, run build_runner again, and the docs change with it.
6. Make the docs better
Write a /// comment on a controller method. The first line is the summary. The rest is the description.
Add @Tags and @Response when the automatic result is not what you want. They come from dox_core, so there is no new import.
class PostController {
/// List all posts
///
/// Newest posts are not first. There is no paging yet.
Future<List<Post>> index(DoxRequest req) async {
return Post().all();
}
/// Create a post
@Tags(<String>['Posts'])
@Response(201, Post, description: 'The new post')
Future<DoxResponse> store(SavePost body) async {
Post post = Post()
..title = body.title
..body = body.body;
await post.save();
return response(post).statusCode(201);
}
// ...
}
Run dart run build_runner build again and restart the app. The real result:
"get": {
"tags": ["Post"],
"summary": "List all posts",
"description": "Newest posts are not first. There is no paging yet.",
"operationId": "PostController.index"
},
"post": {
"tags": ["Posts"],
"summary": "Create a post",
"operationId": "PostController.store",
"responses": {
"201": {
"description": "The new post",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Post"}}}
},
"401": {},
"422": {},
"500": {}
}
}
@Response(201, Post)replaces the empty200. The docs now match the real status code.@Tags(['Posts'])moves the route to the group "Posts" in the docs page.@Summary('...')and@Description('...')set the text without a///comment.@ApiIgnore()leaves a method (or a whole controller) out of the docs.
See OpenAPI: change the docs for all annotations.
7. Turn it off in production
You do not need to do anything. enabled is null by default. Then the docs routes are on only when APP_ENV is development or local.
With APP_ENV=production all three routes give 404:
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/docs # 404
curl -o /dev/null -w '%{http_code}\n' http://localhost:3000/openapi.json # 404
To show the docs on staging too, but never in production, set enabled yourself:
OpenApiConfig openApi = OpenApiConfig(
enabled: env.appEnv != 'production',
info: Info(title: 'Blog API', version: '1.0.0'),
registry: openApiRegistry,
);
Warning
enabled: true in production shows every route of your API to anyone. Only do it for a public API.
To give the document to a front-end team without a running server, write it to files with dox openapi:generate.
See OpenAPI: write the files.
Check it
Open http://localhost:3000/docs, find "Create a post" under "Posts", and send a request with "Try it".
See also
- OpenAPI for all options, the Scalar look, Swagger UI and login schemes
- Validation rules for the rules and their JSON Schema keys
- Environments for
APP_ENV