Skip to content

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_runner and dox_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:

lib/config/openapi.dart
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:

lib/config/app.dart
import 'package:blog/config/openapi.dart';

AppConfig appConfig = AppConfig(
  // ...
  /// OpenAPI docs (config/openapi.dart)
  openApi: openApi,
);

2. Build and start

dart run build_runner build --delete-conflicting-outputs
dox serve

.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

curl http://localhost:3000/openapi.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:

lib/app/http/dtos/save_post.dto.dart
@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.

lib/app/http/controllers/post.controller.dart
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 empty 200. 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:

lib/config/openapi.dart
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

curl -s http://localhost:3000/openapi.json | head -n 5
{
  "openapi": "3.1.0",
  "info": {
    "title": "Blog API",
    "version": "1.0.0"

Open http://localhost:3000/docs, find "Create a post" under "Posts", and send a request with "Try it".

See also