winter 1.0.0-rc.5
winter: ^1.0.0-rc.5 copied to clipboard
A Spring-inspired backend framework for Dart on dart:io - routing, filters, DI, JSON mapping, validation, security and consistent errors.
Winter ❄️ #
A backend framework for the Dart enthusiasts #
Winter is a backend framework for Dart, inspired by Spring, ASP.NET Core and NestJS.
It gives you routing, filters, dependency injection, JSON mapping, validation, security and
consistent errors, on top of dart:io and without code generation.
⚠️ Winter is an experimental project, built in our spare time.
1.0.0-rc.5is a release candidate: the API is the one we intend to keep in 1.0, but it may still change before it, and the framework has been used by one real application so far (feather-io). Try it, build with it, and tell us what breaks; use it in production at your own discretion.
What Winter is:
- A framework for REST APIs: routes, filters, JSON in and out, validation, errors, security, configuration, logs, scheduled tasks, OpenAPI, WebSockets and Server-Sent Events.
- Small and explicit: no reflection, no annotations, no code generation; what you register is what runs.
- Tested and documented: about 1200 tests (99.7 % of the lines), a guide per module and an example for every feature.
What Winter is not (yet):
- Proven in production: no app runs on it at scale yet, so expect rough edges.
- A full-stack framework: no database access (bring your own driver), templates, sessions, CSRF protection nor OAuth flows.
- A distributed system: the rate limiter and the scheduled tasks live in one process; several instances need a shared store of their own.
Now that it's clear what Winter is, let's set it up.
Getting started #
Install it #
You need the Dart SDK 3.13 or newer (get Dart) and a Dart project
(dart create my_api). Then add Winter:
dart pub add winter:^1.0.0-rc.5
The version is needed while 1.0.0 is a release candidate: dart pub add winter alone only picks
stable versions. That's all the setup there is: no generator to run, no configuration file.
Your first server #
With Winter installed, the smallest server is one route:
import 'package:winter/winter.dart';
void main() async {
await Winter.start(
router: WinterRouter(
routes: [
Route.get(path: '/hello', handler: (request) => ResponseEntity.ok(body: 'Hello Winter!')),
],
),
);
}
Run it with dart run, and the server answers on port 8080 (the default):
curl localhost:8080/hello # Hello Winter!
That's as simple as it gets, but Winter does a lot more.
Features #
| Feature | What you get | Guide |
|---|---|---|
| Routing | Nested routes, typed path and query params, regex, 404/405, HEAD and OPTIONS, static files, health checks |
routing |
| Filters | Global and route filters, ordered, that see every error | filters |
| Requests and responses | Headers, cookies, every kind of body (JSON, forms, uploads, binary), streams, conditional requests (304/412/428), Prefer, Link pagination, idempotency keys, deprecation headers |
requests and responses |
| JSON | toJson() without interfaces, your own serializers, generics, snake_case, clear errors |
object mapper |
| Validation | Typed validators, nested objects, async rules, a 422 with a code per field | validation |
| Errors | Every error is a Problem Details (RFC 9457); a 500 never leaks details | error handling |
| Security | Authentication filters, roles and permissions, CORS, security headers, a rate limiter | security |
| Dependency injection | Singletons, lazy, factories, one instance per request, async startup, disposal on shutdown | dependency injection |
| Configuration | Typed environment variables, .env files and profiles |
configuration |
| Logging | Console and JSON loggers, a request id and the W3C trace in every log | logging |
| i18n | Messages in the language of the request | i18n |
| Real time | WebSockets that go through the filters first, and Server-Sent Events | routing, SSE |
| OpenAPI | The document of the routes and Swagger UI, schemas from an example and its validate() |
openapi |
| Scheduled tasks | Intervals and cron expressions, started and stopped with the server | scheduling |
| Testing | The whole pipeline in memory, without ports | testing |
| Deployment | Native executables, Docker, graceful shutdown, health checks | deployment |
How it fits together: architecture. Every guide: doc/.
A real API #
Now something closer to what an API does every day: a JSON body turned into an object and validated, a service from dependency injection, a filter that every request goes through, and errors that the client can read.
import 'package:winter/winter.dart';
/// The body of `POST /tasks`: read from JSON, then validated
class NewTask implements Validatable {
final String title;
final int priority;
NewTask(this.title, this.priority);
factory NewTask.fromJson(Map<String, dynamic> json) =>
NewTask(json.field<String>('title'), json.field<int?>('priority') ?? 3);
@override
ConstraintValidatorContext validate() => ConstraintValidatorContext()
..field('title', title).notBlank().size(max: 100)
..field('priority', priority).min(1).max(5);
}
/// What the API answers: written as JSON with its toJson()
class Task {
final int id;
final String title;
final int priority;
Task(this.id, this.title, this.priority);
Map<String, Object> toJson() => {'id': id, 'title': title, 'priority': priority};
}
class TaskService {
final Map<int, Task> _tasks = {};
List<Task> all() => _tasks.values.toList();
Task find(int id) => _tasks[id] ?? (throw NotFoundException(detail: 'Task $id not found'));
Task create(NewTask task) {
final int id = _tasks.length + 1;
return _tasks[id] = Task(id, task.title, task.priority);
}
}
/// A filter: every request must say which app sends it
class ClientFilter extends Filter {
@override
Future<ResponseEntity> doFilter(RequestEntity request, FilterChain chain) async {
if (request.headers['x-client'] == null) {
throw const BadRequestException(detail: 'Send the X-Client header');
}
return chain.doFilter(request);
}
}
WinterRouter router() => WinterRouter(
basePath: '/tasks',
routes: [
Route.get(
path: '/',
handler: (request) => ResponseEntity.ok(body: di.find<TaskService>().all()),
),
Route.get(
path: '/{id|[0-9]+}',
handler: (request) => ResponseEntity.ok(
body: di.find<TaskService>().find(request.pathParam<int>('id')),
),
),
Route.post(
path: '/',
handler: (request) async {
final NewTask newTask = await request.body<NewTask>(); // 400 or 422 if it's wrong
final Task task = di.find<TaskService>().create(newTask);
return ResponseEntity.created(location: '/tasks/${task.id}', body: task);
},
),
],
);
void main() async {
di.put(TaskService());
Winter.context.setUp(
objectMapper: ObjectMapper(deserializers: [Deserializer<NewTask>.json(NewTask.fromJson)]),
);
await Winter.start(
globalFilterConfig: FilterConfig([LoggingFilter(), ClientFilter()]),
router: router(),
);
}
What a client gets (every error is a Problem Details, application/problem+json):
Request (with X-Client: web) |
Response |
|---|---|
POST /tasks {"title": "Write the docs"} |
201, Location: /tasks/1, {"id": 1, "title": "Write the docs", "priority": 3} |
POST /tasks {"title": " ", "priority": 9} |
422 with a violation per field: title (notBlank) and priority (max.inclusive) |
POST /tasks {"priority": 2} |
400, "detail": "$.title: missing" |
GET /tasks/7 |
404, "detail": "Task 7 not found" |
GET /tasks without X-Client |
400, "detail": "Send the X-Client header" |
And it's tested in memory, with the whole pipeline and without opening a port:
import 'package:test/test.dart';
import 'package:winter/winter.dart';
void main() {
final client = WinterTestClient.build(
globalFilterConfig: FilterConfig([ClientFilter()]),
router: router(),
);
test('an invalid task is a 422', () async {
final response = await client.post(
'/tasks',
headers: {'x-client': 'test'},
body: {'title': ' '},
);
expect(response.statusCode, 422);
});
}
From zero to this, step by step: getting started.
Examples #
If you liked it, example/ has much more: many small examples by topic, one file per
case with its test, and complete apps.
| Topic | Cases |
|---|---|
| routing, bodies | Params, nested routes, a CRUD, static files, health checks; every kind of request body |
| object_mapper, validation | Field naming, adapters, PATCH, sealed classes; nested objects, custom and async rules |
| errors, filters | Exceptions of the domain, a handler of your own; tenants, maintenance mode, a response cache |
| security, di | API keys, Basic auth, webhook signatures, login throttling; scoped services, async startup |
| api_patterns | Pagination, ETag and If-Match, content negotiation, downloads, 202 jobs, idempotency keys, Prefer, versioning, request timeout |
| openapi, realtime, scheduling, i18n | Swagger UI, Server-Sent Events, a WebSocket chat, cron tasks, translated messages |
| apps | Authentication with JWT, an orders API, a photo gallery with uploads and a chat, a production setup with Docker |
Powered by Winter #
The projects that run on Winter:
| 🌰 | ➕ | |
|---|---|---|
| feather-io | Almond: coming soon 🤭 | Your project? |
More #
DECISIONS.md: why Winter works the way it does.- Winter and other frameworks: where its ideas come from (Spring, ASP.NET Core, Ktor, NestJS...) and how it compares with the Dart frameworks.
CHANGELOG.mdandROADMAP.md.- Benchmarks: Winter serves ~79 % of the requests per second of raw
dart:io, and the object mapper costs about a tenth more than JSON written by hand.
Contributing #
Issues and pull requests are welcome. CONTRIBUTING.md explains how to set up
the project (FVM), run the checks and the tests, add a language, and write the commits and the
changelog.