Skip to content

Describe request and response models

This guide shows the three ways to get request and response schemas into the document: typed parameters and return types (inferred), forms, and explicit annotations for handlers that work with raw Request/Response. At the end your operations show complete schemas for bodies, success responses and errors.

  • A working gisila_doc controller (see Document an API).
  • Know the schema rules, especially that property names are taken from constructor parameter names.

A non-primitive parameter that is not a Form, Service, Request or RequestContext is the JSON body. Give its class a fromJson(Map<String, dynamic>) constructor so the generated code can build it.

import 'package:gisila_doc/gisila_doc.dart';
part 'products_controller.g.dart';
class CreateProduct {
CreateProduct({required this.name, required this.priceCents, this.tags = const []});
factory CreateProduct.fromJson(Map<String, dynamic> json) => CreateProduct(
name: json['name'] as String,
priceCents: json['priceCents'] as int,
tags: (json['tags'] as List?)?.cast<String>() ?? const [],
);
final String name;
final int priceCents;
final List<String> tags;
}
class Product {
Product({required this.id, required this.name, required this.priceCents});
final String id;
final String name;
final int priceCents;
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'priceCents': priceCents};
}
@Controller('/products', ['Products'])
class ProductsController {
@Post('/')
Future<Product> create(CreateProduct body, ProductService products) =>
products.create(body);
}

The document gets CreateProduct (with name and priceCents required; tags has a default, so it is optional) and Product components. A payload that fails fromJson with a TypeError or FormatException is answered with 400.

Keep the fromJson/toJson keys equal to the constructor parameter names. The schema uses the Dart names, so toJson() emitting price_cents would make the document wrong.

A Form subclass parameter is bound with bindForm and validated by its fields. It is documented as application/json and application/x-www-form-urlencoded, or as multipart/form-data when it has a FileField.

class AvatarForm extends Form {
final caption = StringField(name: 'caption');
final image = FileField(name: 'image', required: true);
@override
List<FormField<Object?>> collectFields() => [caption, image];
}
@Put('/{id}/avatar', summary: 'Upload an avatar')
Future<void> avatar(String id, AvatarForm form, UserService users) =>
users.saveAvatar(id, form);

Form properties are all documented as optional; the runtime still enforces required: true.

Each instantiation of a generic class is its own component, so a Page<T> wrapper documents correctly for every T:

class Page<T> {
Page({required this.items, required this.total});
final List<T> items;
final int total;
Map<String, dynamic> toJson() => {'items': items, 'total': total};
}
@Get('/')
Future<Page<Product>> list(ProductService products, {int? page}) =>
products.page(page ?? 1);

This produces a Page_Product component whose items reference Product.

Add each error status with @ApiResponse. Give it a bodyType when the body has a fixed shape.

class ConflictError {
ConflictError({required this.message, required this.existingId});
final String message;
final String existingId;
}
@Post('/')
@ApiResponse(409, description: 'SKU already exists', bodyType: ConflictError)
Future<Product> create(CreateProduct body, ProductService products) =>
products.create(body);

400 (for routes that bind input), 401 and 403 (for guarded routes) are added for you with gisila’s error envelope. Declaring the same code with @ApiResponse replaces the automatic one.

When a handler parses the request itself and returns a Response, the generator cannot see the shapes. Describe them:

@Post(
'/import',
summary: 'Bulk import products',
body: BodyDoc(ImportRequest, description: 'Products to import'),
responses: [
Returns(statusCode: 202, bodyType: ImportJob, description: 'Import queued'),
Returns(statusCode: 413, description: 'Too many products'),
],
)
@QueryDoc('dryRun', type: bool, description: 'Validate without saving')
Future<Response> importProducts(Request request) async {
final dryRun = request.url.queryParameters['dryRun'] == 'true';
final json = await readJsonBody(request) as Map<String, dynamic>;
final job = await importer.start(json, dryRun: dryRun);
return jsonResponse(body: job, statusCode: 202);
}

6. Register schemas the generator cannot derive

Section titled “6. Register schemas the generator cannot derive”
spec.putSchema('GeoPoint', {
'type': 'object',
'properties': {
'lat': {'type': 'number'},
'lng': {'type': 'number'},
},
'required': ['lat', 'lng'],
});

putSchema keeps the first registration of a name, so call it before attachToApp if you want to replace a generated schema with your own.

Terminal window
dart run build_runner build
dart run bin/server.dart &
curl -s localhost:8080/openapi.json | jq '.components.schemas | keys'
# ["ConflictError", "CreateProduct", "ImportJob", "ImportRequest", "Page_Product", "Product"]

In Swagger UI, expand an operation and switch the response to Schema to check required fields and nullability.