spring_boot_starter_web
No backward compatibility before 1.0. Any release may rename or change APIs, properties and behaviour; the breaking changes are listed in each package's CHANGELOG.
A web server for spring_boot_dart
applications. You write controller classes whose methods carry
annotations such as @GetMapping('/books/{isbn}'); the starter routes
each HTTP request to the right method, fills its parameters from the
path, query, headers and body, and turns what it returns into the
response.
It takes care of the work around a REST API that you would otherwise write by hand:
- JSON in and out, through the
ObjectMapperofspring_core_dart; - validation of request bodies with
@Valid(); - errors as standard
ProblemDetailJSON bodies; - server-sent events from a
Stream; - file uploads (
multipart/form-data) and downloads withRangesupport; - filters, interceptors, CORS and sessions.
Use it for a backend in Dart, or for a local server inside a Flutter app
on mobile or desktop (a local API, a companion server). The server is
dart:io's HttpServer, behind a factory you can replace. The
generator writes all the routing code at build time: there is no
reflection.
@RestController()
@RequestMapping('/api/books')
class BookController {
BookController(this.books); // injected like any bean
final BookService books;
// GET /api/books/978-0 → the book as JSON
@GetMapping('/{isbn}')
Future<Book> find(@PathVariable() String isbn) => books.find(isbn);
// GET /api/books?q=dune&page=2
@GetMapping()
Future<List<Book>> search(
@RequestParam('q') String query, {
@RequestParam('page') int page = 0, // missing: the Dart default
}) => books.search(query, page: page);
// POST /api/books → 201 Created with a Location header; an invalid body is a 400
@PostMapping()
Future<ResponseEntity<Book>> create(@RequestBody() @Valid() NewBook book) async {
final created = await books.create(book);
return ResponseEntity.created(Uri.parse('/api/books/${created.isbn}'))
.body(created);
}
// DELETE /api/books/978-0 → 204 No Content
@DeleteMapping('/{isbn}')
@ResponseStatus(HttpStatus.noContent)
void delete(@PathVariable() String isbn) => books.delete(isbn);
// A browser's EventSource gets every change as it happens.
@GetMapping('/changes')
EventStream<Book> changes() => EventStream(
() => books.changes().map((b) => ServerSentEvent(b, id: b.isbn)),
);
}
class NewBook { // introspected for JSON: the controller uses it
const NewBook({required this.isbn, required this.title});
@NotBlank()
@Size(min: 10, max: 13)
final String isbn;
@NotBlank()
final String title;
}
# lib/resources/application.properties
server.port=8080
An error is answered with a ProblemDetail, for example when
books.find throws a ResponseStatusException(HttpStatus.notFound, 'No book 123'):
GET /api/books/123 → 404 application/problem+json
{"type":"about:blank","title":"Not Found","status":404,"detail":"No book 123","instance":"/api/books/123"}
Spring (Java):
@RestController
@RequestMapping("/api/books")
class BookController {
@GetMapping("/{isbn}")
Book find(@PathVariable String isbn) { ... }
@PostMapping
ResponseEntity<Book> create(@RequestBody @Valid NewBook book) { ... }
}
In the guides: server, 3. Web.
Setup
dependencies:
spring_boot_starter_web: ^0.6.0
dev_dependencies:
build_runner: ^2.17.0
spring_core_dart_generator: ^0.6.0 # required: describes the controllers
spring_boot_starter_test: ^0.6.0 # MockMvc, springBootTest
test: ^1.25.0
// bin/main.dart
import 'package:spring_boot_starter_web/spring_boot_starter_web.dart';
import 'main.spring.dart';
@SpringBootApplication()
void main(List<String> args) => runApplication(args);
dart run build_runner build
dart run bin/main.dart
# INFO Web server started on port 8080 (http)
Nothing else to switch on (there is no @EnableWebMvc): every
@RestController in the scanned package is mapped, and the server
starts once the context is ready. One import is enough:
package:spring_boot_starter_web/spring_boot_starter_web.dart also
exports spring_boot_dart, spring_web_dart (the annotations,
ResponseEntity, ProblemDetail, the server request and response) and
spring_boot_starter_logging. As with every Spring Boot starter, it is
the only dependency the application needs.
The classes the controllers take and return are introspected for JSON
automatically when they are in the same package, each for what the
controllers do with it: a class that is only returned gets its getters, a
@RequestBody its constructor and setters (and, with @Valid(), the
getters of the properties that have constraints). A class used some other
way the build cannot see, such as a value of type Object that is
written as JSON, fails with a message that says to add @Introspected(),
which describes everything.
Stopping with Ctrl+C. For Ctrl+C or SIGTERM to stop the server
gracefully, the application must close its context on those signals:
set spring.main.register-shutdown-hook=true. It is false by default
here, unlike in Spring Boot; see
Configuration.
Controllers
A controller is a bean whose methods answer HTTP requests.
@RestController() declares one; @RequestMapping('/api/books') on the
class is the path prefix of every method, and its produces, consumes
and headers apply to all of them. A plain @Controller() is mapped
the same way: there are no views, so the return value is always the
response body.
| Annotation | Request |
|---|---|
@GetMapping('/{isbn}') |
GET |
@PostMapping(), @PutMapping, @PatchMapping, @DeleteMapping |
POST, PUT, PATCH, DELETE; without a path, the class's path |
@RequestMapping.of(path: '/x', method: HttpMethod.head) |
any method |
@GetMapping.of(path: '/x', produces: 'text/csv', consumes: ..., headers: ['X-Api=2']) |
the same, with conditions: produces must suit the Accept header and consumes the Content-Type (comma-separated media types); headers are Name, !Name, Name=value, Name!=value |
Paths are path patterns (PathPattern of spring_web_dart), and
${...} placeholders in them are filled from the configuration:
| Pattern | Matches |
|---|---|
/books |
the path itself; a trailing slash matters (/books/ is another path, as in Spring 6) |
/books/{isbn} |
one segment, captured as isbn (decoded) |
/books/{isbn:\d+} |
one segment matching the regular expression |
/files/{*path} |
the rest of the path, with its leading / |
/static/**, /b?ok, /*.json |
wildcards: any number of segments, one character, part of a segment |
Matrix parameters are left out of the matching, as in Spring:
/books/1111;v=1 is /books/1111. The server answers a path with an
encoded /, \ or NUL (%2F, %5C, %00) with a 400 before any
filter, as Tomcat does, so ..%2F..%2Fsecret never reaches a path
variable; so is a . or .. segment that matrix parameters hide
(/files/..;/secret), and a % escape in the path or the query that is
not UTF-8 (/%FF, /%C0%AF, ?id=%FF). A raw \ (not encoded) never
reaches the server: dart:io reads it as / first (see
Differences from Spring).
When several mappings match, the most specific pattern wins
(/books/new before /books/{isbn}), then the mapping with more
conditions. As in Spring, every GET mapping also answers HEAD (the
headers and Content-Length, no body), and OPTIONS is answered with
an Allow header.
Parameters
Each parameter of a controller method says where its value comes from.
| Parameter | Value |
|---|---|
@PathVariable() String isbn |
{isbn} of the path; @PathVariable('id') for another name |
@RequestParam('q') String query |
a query parameter, or else a field of a form body (application/x-www-form-urlencoded) or a text field of a multipart form. List<int> ids takes ?id=1&id=2 or ?id=1,2; a Map<String, String> takes all of them; a MultipartFile takes the uploaded file of that name |
@RequestHeader('X-Trace') String? trace |
a header; an HttpHeaders or Map parameter takes all of them |
@CookieValue('session') String session |
a cookie |
@RequestBody() Book book |
the body: JSON through the ObjectMapper (application/json, *+json). Without a Content-Type the body is application/octet-stream, as in Spring: a 415 for an object in a POST, PUT or PATCH, a missing body when there is none. Also a String, Uint8List or List<int> (any Content-Type), a Stream<Uint8List> (unread, for big uploads), or a Map from a form (application/x-www-form-urlencoded) |
@RequestBody() @Valid() NewBook book |
the body, validated (@Validated([Group]) for groups): a violation is a 400 |
@RequestPart('file') MultipartFile file |
a part of a multipart/form-data upload: a file, a text field or a JSON part; see Files |
ServerHttpRequest, ServerHttpResponse |
the request and the response |
HttpHeaders, HttpMethod, Uri |
the request's headers, method and URI |
Principal |
the user an authentication filter set (request.principal); a non-nullable Principal without one is a 401 |
a simple value or a MultipartFile without annotation (String q, int page) |
the query parameter (or uploaded file) of that name, as if it had @RequestParam |
Simple values are String, int, double, num, bool (true,
on, yes, 1, ...), BigInt, DateTime (ISO 8601), Duration
(10s), Uri, enums (by name in any case, or by @JsonValue), and
lists or sets of them. A text that does not convert is a 400 that names
the parameter.
Required or optional comes from the Dart parameter, where Spring has
required and defaultValue:
| Value missing | Result |
|---|---|
nullable parameter (String? q) |
null |
optional named or positional parameter ({int page = 0}) |
left out of the call: its Dart default applies |
| otherwise | 400 Required parameter 'q' is not present. (also for headers and cookies) |
So @RequestParam(name = "sort", required = false) String sort is
@RequestParam('sort') String? sort, and @RequestHeader(name = "X-Lang", defaultValue = "en") String lang is @RequestHeader('X-Lang') String lang = 'en'.
@Valid() uses the application's Validator bean. With several
Validator beans and none of them @Primary, no validator is chosen and
@Valid() bodies are not checked, without a warning.
Results
What a method returns decides the response:
| Return type | Response |
|---|---|
void, null, Future<void> |
the status (200, or @ResponseStatus), no body |
an object, List, Map |
JSON through the ObjectMapper (application/json, or the mapping's produces); 406 when Accept excludes it |
String |
text/plain;charset=UTF-8 (or produces) |
Uint8List |
application/octet-stream (or produces) |
Resource (FileSystemResource, ByteArrayResource) |
a download: the media type from the file name, Content-Length, Range and Last-Modified; see Files |
Stream<Uint8List>, Stream<List<int>> |
raw bytes as they come (application/octet-stream, or produces) |
StreamingResponseBody |
bytes the handler writes itself |
ResponseEntity<T> |
its status, headers and body (ResponseEntity.ok(x), .created(uri).body(x), .notFound().build()); with an ETag or Last-Modified, conditional GETs and HEADs get a 304 (see below) |
ProblemDetail, an ErrorResponse |
application/problem+json with its status |
EventStream<T>, Stream<T> (other than bytes) |
server-sent events (text/event-stream); NDJSON with produces: 'application/x-ndjson' |
SseEmitter |
server-sent events sent from anywhere |
A Future of any of these is awaited. A Future of a stream is not
allowed: return the stream itself (the startup check reports it).
@ResponseStatus(HttpStatus.created) sets the status of a normal
answer. With a reason, @ResponseStatus(HttpStatus.gone, reason: 'Retired'), the result is ignored and the answer is an error with that
detail.
A ResponseEntity with a status of 200 and an ETag or Last-Modified
(.eTag(book.version), .lastModified(book.updatedAt)) answers a
browser or cache that revalidates, as Spring's
HttpEntityMethodProcessor does, for GET and HEAD:
If-None-Match with its tag (compared weakly: W/ is ignored; a list,
or several headers) is a 304 without a body, and so is, without
If-None-Match, an If-Modified-Since at or after Last-Modified
(whole seconds); an If-Unmodified-Since before it is a 412. The 304
keeps the other headers (Cache-Control, Vary, the CORS ones). As in
Spring, If-None-Match: * is not a match for a GET, other methods'
If-Match and If-Unmodified-Since are not checked (the handler checks
them), and neither are the bodies written as they come (event streams,
StreamingResponseBody).
Checked at startup
Every controller is checked when the context starts, so a mistake shows up at once instead of on the first request. The application stops with the whole list when it finds:
- a
{variable}without a@PathVariableparameter, or the other way round; - a parameter nothing can fill, or a
@RequestParamthat is not a simple value; - two
@RequestBodyparameters; - two methods mapping the same request (method, pattern and conditions);
- an invalid
produces; - an
@ExceptionHandlerwithout an exception; - a method returning a
Futureof a stream.
Invalid controllers:
- BookController.find: the path variable {isbn} of "/api/books/{isbn}" has no @PathVariable parameter
- Ambiguous mapping: OrderController.list and OrderController.all both map GET /api/orders
Set logging.level.spring.web.mvc=debug to see every mapping
(Mapped GET /api/books/{isbn} -> BookController.find).
Errors: ProblemDetail
Errors are answered with a problem (RFC 9457,
application/problem+json): a JSON body with the status, a title and a
detail, which clients can read the same way for every error. It is
Spring 6's ProblemDetail. Two kinds of answers are plain text instead,
as in Spring: a refused CORS request (a 403 with Invalid CORS request, as Spring's DefaultCorsProcessor answers), and a WebSocket
handshake the WebSocket starter refuses (a 403 for an origin or a
HandshakeInterceptor, a 400 for a request that asks for no upgrade
or a path variable that does not convert, as Spring's handshake
handler). The server's own checks of a handshake (a repeated or
malformed Sec-WebSocket-Key: 400; another Sec-WebSocket-Version: 426
with Sec-WebSocket-Version: 13) are problems.
| Cause | Status |
|---|---|
| no mapping for the path | 404 |
| the path is mapped, but not for this method | 405 with Allow |
Content-Type not in consumes, not JSON (or missing) for a @RequestBody object, or not a media type at all |
415 with Accept |
nothing in produces (or JSON) suits Accept |
406 |
a required value missing, a value that does not convert, an unreadable body, a form with a % escape that is malformed or not UTF-8, unmet headers conditions |
400 |
a request body that sends nothing for server.connection-timeout (60 s) |
408 |
@Valid violations |
400 with errors: [{"field": "isbn", "message": "...", "rejectedValue": ""}] (messages from the MessageSource) |
a body above server.max-http-request-body-size |
413 |
an upload above spring.web.multipart.max-file-size or max-request-size, or with more than max-part-count parts |
413 (MaxUploadSizeExceededException) |
a malformed multipart request, or @RequestPart on a request that is not multipart |
400 (MultipartRequestException) |
throw ResponseStatusException(HttpStatus.notFound, 'No book 9') |
its status and detail |
an ErrorResponse or ErrorResponseException |
its status, headers and problem |
an error a HandlerExceptionResolver answers (the data starter's DuplicateKeyException is a 409) |
its status, headers and problem |
| any other exception | 500 Internal Server Error, with no detail; the exception is logged with its stack trace under spring.web.mvc |
answered by the server before any filter: a method HttpMethod does not know, a missing client certificate (server.ssl.client-auth=need), a malformed or missing Host (or one that is not the host of an absolute target), an encoded /, \ or NUL in the path, a . or .. segment behind matrix parameters, a % escape that is not UTF-8 |
501, 403, 400, 400, 400, 400 |
| a handler fails after the response was committed | no error answer can follow: the connection is dropped without finishing the response, as Tomcat does |
A 5xx answer that an exception asks for (a ResponseStatusException,
the 503 of a refused session) is logged as a warning without a stack
trace, at most once every 10 seconds for the same kind; only unexpected
exceptions are logged as errors.
To answer an exception your own way, write an @ExceptionHandler
method. On a controller it handles the errors of that controller's
methods; on a @ControllerAdvice or @RestControllerAdvice bean it
handles them for every controller. The method takes the exception, and
optionally the ServerHttpRequest, ServerHttpResponse or
HttpHeaders, and returns whatever a handler may return:
@RestControllerAdvice()
class ApiErrors {
// The parameter's type picks the exceptions; the isbn helps the client.
@ExceptionHandler()
@ResponseStatus(HttpStatus.notFound)
ProblemDetail notFound(BookNotFoundException e) =>
ProblemDetail.forStatusAndDetail(HttpStatus.notFound, 'No book ${e.isbn}')
..setProperty('isbn', e.isbn);
// A ResponseEntity when the answer needs its own headers or status.
@ExceptionHandler()
ResponseEntity<ProblemDetail> conflict(StateError e) =>
ResponseEntity.status(HttpStatus.conflict)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.conflict, e.message));
// Listed types match by exact runtime type; nothing to say but the status.
@ExceptionHandler([TimeoutException])
@ResponseStatus(HttpStatus.gatewayTimeout)
void timeout() {}
}
A controller's own handlers come first. Then the advice beans are
tried in @Order, as in Spring: the first bean that has a handler for
the error answers it, even when a later bean has a more specific one.
Within one controller or bean, the handler for the most specific type
wins: an exact type listed in @ExceptionHandler([...]) first, then the
parameter type that is a subtype of the others. Advice handlers also see
the 404, 405, 406 and 415 errors of the lookup and the errors of other
handler mappings (WebSocket handshakes). An exception thrown by a filter
skips them and gets the default answer.
An advice applies to every controller unless @ControllerAdvice.of (or
@RestControllerAdvice.of) narrows it, as Spring's attributes do: to
the controllers of some libraries (basePackages, prefixes of library
URIs instead of Java packages), of some types (assignableTypes, their
subtypes too) or carrying some annotation (annotations). A narrowed
advice does not see errors raised before a controller method was chosen
(a 404, a 405).
// Only the admin controllers answer validation errors this way.
@RestControllerAdvice.of(basePackages: ['package:shop/admin/'])
@Order(1)
class AdminErrors { ... }
Resolvers: errors a library maps
A HandlerExceptionResolver bean turns an error into an answer for the
errors no @ExceptionHandler method handles. It is how a library maps
its own exceptions to HTTP without the application writing an advice for
them. The resolvers are asked after the @ExceptionHandler methods of
the controller and of the advice beans (the application's own handlers
always win), in @Order, before the default 500; the first that returns
an ErrorResponse (status, headers and problem) answers, null passes
the error on. They are not asked for an error that is already an
ErrorResponse (a ResponseStatusException, the router's 404, 405, 406
and 415) nor for one a filter throws, and a resolver that throws is
logged and skipped. The starter logs each resolved error at debug level
under spring.web.mvc.
@Component()
class RoomTakenResolver implements HandlerExceptionResolver {
@override
ErrorResponse? resolveException(
ServerHttpRequest request,
Object error, {
HandlerMethod? handler,
}) => error is RoomTakenException
? ResponseStatusException(HttpStatus.conflict, 'The room is taken')
: null;
}
An application that also depends on spring_boot_starter_data gets such a
resolver without writing one: the generator adds the bean
dataAccessExceptionResolver to the application's module, because
neither starter may depend on the other (the same way it adds the
database driver). Declare a bean of that name to replace it.
| Exception | Status | Detail |
|---|---|---|
OptimisticLockingFailureException (a @Version conflict) |
409 | The resource was changed by someone else |
DuplicateKeyException |
409 | The resource already exists |
EmptyResultDataAccessException |
404 | Not found |
The detail is fixed, never the exception's message, which may hold SQL, table names or values. Subtypes are mapped too; every other data error stays a 500 without details, as any other exception.
Server-sent events
Server-sent events let the server push a series of messages over one
long response, which a browser reads with EventSource: notifications,
progress, live data. Return an EventStream<T>, or any Stream<T>
that is not a byte stream (see Files).
Each item is one event, its data written as JSON (a String as it is).
ServerSentEvent<T> items keep their id, event, retry and
comment. The stream is listened to when the response starts and
cancelled when the client goes away. It is paused while an event is
being written, so a client that reads slowly slows the stream down
instead of filling the server's memory. A ResponseEntity of an
EventStream, a Stream or an SseEmitter writes the same events
with its own status and headers.
// Every change, with the isbn as the event id.
@GetMapping('/changes')
EventStream<Book> changes() => EventStream(
() => books.changes().map((book) => ServerSentEvent(book, id: book.isbn)),
);
// A reconnecting EventSource sends the last id it saw: resume from there.
@GetMapping('/resume')
Stream<Book> resume(@RequestHeader('Last-Event-ID') String? lastId) =>
books.changesAfter(lastId);
// One JSON object per line instead of events.
@GetMapping.of(path: '/export', produces: 'application/x-ndjson')
Stream<Book> export() => books.all();
Any method may answer with a stream, POST included, as MCP's
Streamable HTTP does.
SseEmitter is for events that come from elsewhere, later: a
service or an event listener. The controller method creates and returns
the emitter, the application keeps it, and other code calls send.
Events sent before the response is ready are buffered.
@GetMapping('/notifications')
SseEmitter subscribe() {
final emitter = SseEmitter(timeout: const Duration(minutes: 30));
notifications.add(emitter);
// Forget the emitter once the client is gone or it timed out.
emitter.onCompletion(() => notifications.remove(emitter));
return emitter;
}
// Elsewhere, when an order is placed:
for (final emitter in notifications) {
emitter.send(ServerSentEvent(order, event: 'order', id: order.id));
}
An emitter has send, complete(), completeWithError(e),
onCompletion, onTimeout, onError and isComplete. Sending after
the client went away throws a StateError. Await send to go at the
client's pace; when sends are not awaited and 1000 events wait for a
client that reads nothing, send fails with a StateError and the
emitter ends. Without its own timeout, an emitter ends after
spring.mvc.async.request-timeout, or never when that is not set (in
Spring on Tomcat, the container's 30 seconds apply).
Proxies often close connections that stay quiet.
spring.mvc.sse.heartbeat-interval=15s writes a comment line at that
interval to keep idle streams open (a property of this starter, not of
Spring Boot).
dart:io sends the response headers only with the first bytes of the
body. So that the client sees the response before the first event,
every text/event-stream response starts with an empty comment line
(:), which clients ignore. When the server stops, open streams are
closed.
Files: uploads and downloads
Uploads: multipart/form-data
A browser form with enctype="multipart/form-data", curl -F or a
Feign client sends files as a multipart request. The starter reads it
once per request and binds its parts to the handler's parameters:
@PostMapping.of(path: '/api/books', consumes: MediaType.multipartFormData)
Future<Book> create(
@RequestPart('book') @Valid() NewBook book, // a JSON part, validated
@RequestPart('cover') MultipartFile cover, // a file
@RequestParam('note') String note, { // a text field
@RequestPart('pages') List<MultipartFile>? pages, // optional
}) async {
final created = await books.create(book, note: note);
// Keep the cover: temporary files are deleted when the request ends.
await cover.transferTo('/var/shop/covers/${created.isbn}.png');
return created;
}
| Parameter | Value |
|---|---|
@RequestPart('file') MultipartFile file |
the file of that part (the first one if there are several) |
@RequestPart('files') List<MultipartFile> files |
every file of that name. When there is none: a 400 if required, null if nullable, an empty list for a non-nullable optional parameter |
@RequestPart('note') String note, int, ... |
a text field, converted like a request parameter (or the text of a file part) |
@RequestPart('data') Uint8List data |
the raw bytes of the part (List<int> too) |
@RequestPart('book') @Valid() NewBook book |
a part read as JSON into the type through the ObjectMapper, validated with @Valid |
@RequestPart('cover') MultipartFile? cover |
optional: null when there is none |
@RequestParam('file') MultipartFile file |
the same as @RequestPart, for files |
@RequestParam('note') String note |
a text field of the form (the query string is looked at first) |
MultipartFile file without annotation |
the file named like the parameter |
A missing required part is a 400 Required part 'cover' is not present., and @RequestPart on a request that is not multipart is a
400.
MultipartFile (from spring_web_dart) has name, originalFilename,
contentType, size, isEmpty, headers, bytes (a Future),
openRead() (a stream, without loading the file) and
transferTo(path), which moves the temporary file to path (or copies
it when it cannot be moved). After transferTo, the file can no longer
be read or moved again.
Limits and temporary files use Spring Boot's
spring.web.multipart.*
keys:
spring.web.multipart.max-file-size=5MB # one file (or part); 1MB by default, -1: no limit
spring.web.multipart.max-request-size=20MB # the whole request; 10MB by default, -1: no limit
spring.web.multipart.max-part-count=100 # files and fields together; 50 by default, -1: no limit
spring.web.multipart.file-size-threshold=64KB # smaller files stay in memory; 0B by default: all go to disk
spring.web.multipart.location=/var/tmp/shop # a private directory is made in it; the system's temporary directory by default
spring.web.multipart.enabled=false # don't parse multipart requests
- Above a limit, the upload is a 413 (
MaxUploadSizeExceededException) and reading stops there. The resolver reads uploads withmax-request-sizeinstead ofserver.max-http-request-body-size; anything else that reads a multipart body (a@RequestBody() String) keeps the server's limit.max-part-countis this starter's own key: Spring Boot sets the same limit on Tomcat withserver.tomcat.max-part-count(50). - Files are streamed to temporary files as they arrive, so a whole
upload is never held in memory. They are deleted when the request
ends, whatever the handler did: keep a file with
transferToor by copyingopenRead(). An upload that fails on the way (a limit, a broken body, a client that goes away) leaves nothing behind either. The files are in a directory of the process that only its user can open (0700), with random names. Withoutdart:io(on the web), files stay in memory.
To send files from a client, see the Feign starter's Files and multipart.
Downloads: Resource
To serve a file, return a Resource from spring_core_dart
(FileSystemResource, ByteArrayResource). The starter streams it and
answers the HTTP caching and range headers for you:
// A cover image from disk. The pattern lets only digits into the file
// name: a path variable is decoded, so never put it into a path as it is.
@GetMapping(r'/api/books/{isbn:\d+}/cover')
Resource cover(@PathVariable() String isbn) =>
FileSystemResource('/var/shop/covers/$isbn.png');
// A generated report the browser saves as report.csv.
@GetMapping('/api/report')
ResponseEntity<Resource> report() => ResponseEntity.status(HttpStatus.ok)
.contentType('text/csv')
.header('Content-Disposition', ['${ContentDisposition.attachment(filename: 'report.csv')}'])
.body<Resource>(ByteArrayResource(utf8.encode(reports.csv())));
- Headers:
Content-Typefrom the file name (MediaTypeFactory;application/octet-streamwhen unknown) unless theResponseEntitysets one,Content-Length, andAccept-Ranges: bytes. AResponseEntity<Resource>keeps its status and headers, such as aContent-Dispositionthat names the download. - A file is streamed from disk (
FileSystemResource.openRead(start, end)), never read whole. A resource that does not exist is a 404. Last-Modified(files): aGETorHEADwith anIf-Modified-Sinceat or after it is a 304 without a body.Range: one range (bytes=0-99,bytes=500-,bytes=-100) is a 206 withContent-Range; a range past the end is a 416 (Content-Range: bytes */length). Several ranges or a malformed header get the whole content (200), as RFC 9110 allows.If-Rangewith a date applies the range only if the file has thatLast-Modified.HEADanswers the same headers without the body.- A path variable in a file name: the server refuses an encoded
/,\or NUL in the path and./..segments (MockMvcdoes not), but a variable can still hold characters a file name should not; restrict it with a pattern ({isbn:\d+}) or check it before building the path.
Byte streams and StreamingResponseBody
A handler declared to return Stream<Uint8List> or
Stream<List<int>> writes the chunks as raw bytes, as they come
(application/octet-stream, or the mapping's produces). Any other
Stream<T> is still server-sent events. For a body produced on the
fly, return a StreamingResponseBody: the handler writes to a
ResponseBodySink (add(bytes), write(text), flush(),
addStream(stream)):
// A CSV export written row by row, never held whole in memory.
@GetMapping.of(path: '/api/books.csv', produces: 'text/csv')
StreamingResponseBody export() => StreamingResponseBody((out) async {
await for (final book in books.all()) {
out.write('${book.isbn},${book.title}\n');
await out.flush();
}
});
@GetMapping('/api/backup')
Stream<Uint8List> backup() => storage.openBackup(); // bytes, not events
Both can be the body of a ResponseEntity, for example to add a
Content-Disposition.
Testing uploads and downloads
await app.mockMvc.multipart('/api/books', files: [
MockMultipartFile('cover', 'dune.png', 'image/png', pngBytes),
MockMultipartFile('book', null, 'application/json', utf8.encode('{"isbn": "9780441013593", "title": "Dune"}')),
], params: {'note': 'first edition'}).andExpect(status: 200);
final result = await app.mockMvc
.get('/api/books/9780441013593/cover', headers: {'Range': 'bytes=0-99'})
.andExpect(status: 206, headers: {'Content-Range': 'bytes 0-99/48213'})
.andReturn();
expect(result.response.bodyBytes, hasLength(100));
A MockMultipartFile without a file name is a plain part; with
application/json it is a JSON part for @RequestPart. More in
docs/testing.md.
Sessions: HttpSession
HTTP forgets everything between two requests. A session remembers data
for one client across requests, such as a shopping cart or the
logged-in user: the data stays on the server, and the client only holds
a random id in a cookie (JSESSIONID by default) that it sends back.
Browsers need this; APIs called by programs usually send a token
instead and never create a session. The security starter keeps the
logged-in user in one.
Code that has the request gets the session with request.getSession().
The first call creates the session and sends its cookie;
getSession(create: false) only looks and returns null when there is
none.
// Each visitor has a cart that lives as long as the session.
@PostMapping('/cart')
void add(ServerHttpRequest request, @RequestParam() String item) {
final session = request.getSession()!;
final cart = session.getAttribute('cart') as List<String>? ?? [];
session.setAttribute('cart', [...cart, item]);
}
A session has id, creationTime, lastAccessedTime,
maxInactiveInterval, isNew, getAttribute, setAttribute
(null removes), removeAttribute, attributeNames, invalidate()
(as a logout does) and isInvalidated. After a login, call
await request.changeSessionId(): the session keeps its data under a
new id, so an id someone saw before the login stops working. The
client never chooses an id: an unknown id from a cookie is ignored.
server.session.timeout=15m # without a request; 30m by default, seconds without a unit
server.session.cookie.name=SHOPSESSION # JSESSIONID by default
server.session.cookie.same-site=strict # lax by default; strict, none, or unset
server.session.max-sessions=50000 # 10000 by default; -1: no limit
| Property | Default |
|---|---|
server.session.timeout |
30m |
server.session.cookie.name |
JSESSIONID |
server.session.cookie.path |
the context path, or / |
server.session.cookie.domain |
none |
server.session.cookie.http-only |
true |
server.session.cookie.secure |
secure when the request came over HTTPS |
server.session.cookie.same-site |
lax |
server.session.max-sessions |
10000 (-1: no limit) |
The number of sessions is limited (max-sessions). At the limit, a new
session first removes every expired one; when the store is still full,
the new session is refused with a TooManyActiveSessionsException, a
503, and a warning is logged (at most once a minute). A live session
is never removed to make room. As in WebFlux's InMemoryWebSessionStore
(maxSessions, expired ones removed, then refused). The security starter
creates a session only at a login, so anonymous requests cannot fill the
store; raise the limit when logged-in users alone fill it.
Sessions live in memory by default (InMemoryHttpSessionRepository):
they are lost when the process stops and are not shared between
several instances of the application. To keep them in a database or
Redis, declare your own HttpSessionRepository bean; the in-memory one
then backs off. When a repository answers with Futures, use
request.getSessionAsync().
Spring (Java), where the session can also be a handler parameter:
@PostMapping("/cart")
void add(HttpSession session, @RequestParam String item) {
List<String> cart = (List<String>) session.getAttribute("cart");
...
session.setAttribute("cart", cart);
}
Spring Boot leaves SameSite unset and has no session limit of its
own (Tomcat's maxActiveSessions, unlimited by default, refuses every
new session above it). Here the cookie is SameSite=Lax, and the
number of sessions is limited so clients cannot fill the memory by
opening sessions; at the limit a session without a login makes room
(with a warning, at most once a minute), as described above.
Filters
A filter runs around every request, before the controller is even
looked up: request ids, logging, authentication. Declare a WebFilter
bean; filters run in @Order. The security starter's filter chain is
one of them. OncePerRequestFilter adds shouldNotFilter to skip some
requests:
// Every answer carries an id the logs can be searched by.
@Component()
@Order(Ordered.highestPrecedence)
class RequestIdFilter extends OncePerRequestFilter {
@override
bool shouldNotFilter(ServerHttpRequest request) => request.path == '/health';
@override
Future<void> doFilterInternal(request, response, chain) {
response.headers.set('X-Request-Id', newId());
return chain.doFilter(request, response);
}
}
A filter can answer itself by not calling the chain, or pass a wrapped
request or response on: ServerHttpRequestDecorator and
ServerHttpResponseDecorator forward everything and let you override
one member (the path, the headers, the principal).
Every request runs in its own Zone, so
RequestContextHolder.currentRequest is the request anywhere in its
handling, across awaits.
Interceptors and argument resolvers: WebMvcConfigurer
A bean that mixes in WebMvcConfigurer customizes how controllers are
called:
- interceptors run before and after the controller method, and know which method it is (unlike a filter);
- CORS mappings set CORS rules for path patterns (see CORS);
- argument resolvers fill parameters of your own types.
@Configuration()
class WebConfig with WebMvcConfigurer {
@override
void addInterceptors(InterceptorRegistry registry) => registry
.addInterceptor(TimingInterceptor())
.addPathPatterns(['/api/**'])
.excludePathPatterns(['/api/health']);
@override
void addCorsMappings(CorsRegistry registry) => registry
.addMapping('/api/**')
.allowedOrigins(['https://shop.example'])
.allowedMethods(['GET', 'POST']);
@override
void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) =>
resolvers.add(TenantResolver());
}
// Logs how long each controller method took.
final class TimingInterceptor with HandlerInterceptor {
@override
bool preHandle(request, response, handler) { // false: the interceptor answered
request.attributes['start'] = DateTime.now();
return true;
}
@override
void afterCompletion(request, response, handler, error) =>
log.info('${handler.description} took '
'${DateTime.now().difference(request.attributes['start'] as DateTime)}');
}
// A `Tenant tenant` parameter gets the tenant of the X-Tenant header.
final class TenantResolver implements HandlerMethodArgumentResolver {
@override
bool supportsParameter(MethodParameter parameter) => parameter.type.type == Tenant;
@override
Object? resolveArgument(parameter, request, response) =>
Tenant(request.headers['X-Tenant'] ?? 'default');
}
HandlerInterceptor:preHandle,postHandle(with the result, before it is written),afterCompletion(also after an error; it gets the exception even when an@ExceptionHandleranswered it). As in Spring, an interceptor runs only when aWebMvcConfigurerregisters it: aHandlerInterceptorbean alone is not picked up.HandlerMethodArgumentResolver:supportsParameteris asked once per parameter at startup (parameter.type,parameter.name,parameter.annotation<A>()),resolveArgumentonce per request (it may return aFuture).
CORS
A browser keeps a page of https://shop.example from reading the
answers of https://api.example unless the API allows it with
Access-Control-Allow-... headers (CORS). For most requests the browser
first asks with an OPTIONS "pre-flight" request. The starter answers
both from the rules you declare, in one of three ways:
// On a controller (or a method): the shop's pages may call it.
@RestController()
@CrossOrigin(origins: ['https://shop.example'], maxAge: 3600)
class BookController { ... }
// For many paths at once, a bean (its rules alone, where it has some):
@Bean()
CorsConfigurationSource corsConfigurationSource() => UrlBasedCorsConfigurationSource()
..registerCorsConfiguration(
'/api/**',
CorsConfiguration(
allowedOriginPatterns: ['https://*.example'],
allowCredentials: true,
)..applyPermitDefaultValues(), // GET, HEAD, POST and every header
);
// Or WebMvcConfigurer.addCorsMappings, shown above.
@CrossOriginon a class or method takesorigins,originPatterns,methods,allowedHeaders,exposedHeaders,allowCredentials,allowPrivateNetworkandmaxAge(in seconds). What it leaves out gets these defaults, as in Spring: every origin, the method the handler is mapped to (DELETEfor a@DeleteMapping;GETalone for a@GetMapping, so a cross-originHEADneedsmethods: ['GET', 'HEAD']), every request header, and a pre-flight cache of 30 minutes.WebMvcConfigurer.addCorsMappingsgets the defaults ofapplyPermitDefaultValues()for what a mapping leaves out: every origin,GET,HEADandPOST, every header, 30 minutes. Its list methods add to what is there; Spring's replace it.- A
CorsConfigurationSourcebean namedcorsConfigurationSourceis the rules the security starter's CORS filter applies, as Spring Security applies the bean of that name: where it has a configuration for a request, that configuration alone decides, neither@CrossOriginnor theWebMvcConfigurermappings widen it (Spring MVC leaves a request the filter answered as it is). The router applies it the same way, so CORS works the same with and without the security starter. Where it has none (null),@CrossOriginand the mappings apply. The name counts, not the type: a@BeanmethodcorsConfigurationSource()gives it; a bean of the type with another name (apiCors, or a library's) is ignored, as by Spring Security, and several such beans never fail the start. (Spring MVC alone does not read such a bean.) - A
CorsConfigurationyou build is used as it is: empty lists allow no origin and no request header, and emptyallowedMethodsallow onlyGETandHEAD. CallapplyPermitDefaultValues()for the defaults above.
Every cross-origin request is checked, as Spring's DefaultCorsProcessor
checks it: its origin, and its method, the one a pre-flight announces
or an actual request's own. So an allowed origin's DELETE, PUT,
HEAD or OPTIONS that the rules do not list is refused with a 403
before the handler runs (with the security starter, before the security
checks), even if the browser sent no pre-flight. allowPrivateNetwork
(Chrome's Private Network Access, for a server on localhost or an
office network called from a public site) answers a pre-flight with
Access-Control-Request-Private-Network: true with
Access-Control-Allow-Private-Network: true; like allowCredentials,
it cannot go with all origins (*): @CrossOrigin stops the startup,
a mapping fails every request to its paths (500) and a bean every
cross-origin one, as in Spring.
Behind a proxy that terminates TLS (or that changes the host or the
port), the server sees http and its own address, and the starter does
not read Forwarded or X-Forwarded-* headers yet. Browsers send an
Origin with every POST, PUT, PATCH and DELETE, to their own
site too, so there the application's own pages count as another origin,
and their requests are checked as cross-origin ones: the origin, and the
method. Under addCorsMappings rules for /** with the defaults (GET,
HEAD, POST), a page's own PUT or DELETE is a 403; with origins
that leave out the site's own, its POST too (with or without the
security starter). Spring does the same without
server.forward-headers-strategy. Give CORS rules only to the paths
other sites call (/api/public/** rather than /**), or list the
site's own origin and every method its pages send
(allowedOrigins(['https://shop.example', ...]),
allowedMethods(['GET', 'HEAD', 'POST', 'PUT', 'DELETE'])), or
terminate TLS in the application (server.ssl.*). The security
starter's DPoP proofs have the same limit (see its README).
Origins are checked as Spring's CorsConfiguration checks them, by
AllowedOrigins (from spring_web_dart; the WebSocket starter checks
handshakes with it too):
origins/allowedOriginsare whole origins, compared without case (https://shop.example);*allows any, but not withallowCredentials.originPatterns/allowedOriginPatternsare compared with case:*matches any text, as Spring's.*(https://*.shop.exampletakeshttps://eu.shop.example, nothttps://shop.example;*.shop.exampletakes any scheme and*localhost*anylocalhostorigin), and a port list at the end takes the ports listed (http://localhost:[3000,3001]) or, with[*], any port or none (http://localhost:[*]takeshttp://localhosttoo). The origin is echoed back, so patterns work withallowCredentials.- A value may list several, separated by commas
(
'https://a.example, https://b.example', handy when it comes from one property), and a trailing/is ignored, in the values and in theOriginheader. The allowed origin is echoed as the browser sent it.
As in Spring, https://*.example.com also takes
https://evil.com/.example.com: no browser sends such an Origin, and
the CORS answer binds browsers only (any other client sends what it
likes). Two differences from Spring:
- An
Originlonger than 1024 characters is refused (AllowedOrigins.maxOriginLength; a real origin is under 300: a host has at most 253). A pattern is matched in time that grows with the origin's length, however many*it has, where Spring's regular expressions take the square or the cube of it with two or three. - An
Originwith a character other than visible US-ASCII (a space, a control character, a letter beyond ASCII) is refused with a 403, as Spring refuses one it cannot parse, and so is a pre-flight that asks for such a method or header, even where*allows any: no browser sends one, anddart:iocould not echo it (Spring echoes it).
The rules are combined as in Spring: the global ones (the
WebMvcConfigurer mappings) first, then the class, then the method.
Values one side sets are merged with the other side's (a * makes the
result *), but the defaults of @CrossOrigin or
applyPermitDefaultValues() never widen what the other side sets, as
Spring's default markers: @CrossOrigin(methods: ['PUT']) on a handler
under /api/** keeps the global origins, and adds PUT to the global
methods. @CrossOrigin(allowCredentials: true) without origins or
originPatterns stops the startup, as in Spring (all origins cannot be
allowed with credentials), and so does allowPrivateNetwork: true.
Pre-flight requests (OPTIONS with Origin and
Access-Control-Request-Method) are answered for mapped paths, never
passed to a handler; one for a method no handler of the path maps is
decided by the global rules alone (a 403 when they refuse it, never a
405), as in Spring. A pre-flight to a mapped path without CORS rules
gets a 200 with no CORS header and no Vary, as in Spring: the browser
then blocks the request it announced. On a path with CORS rules, a
cross-origin request from an origin they do not allow is a 403. On a
path without rules, the request is handled without CORS headers, and
the browser keeps the answer from the page.
A pre-flight cannot say which Content-Type or Accept the request it
announces will send, so it is answered from the rules of the first
handler of its path and method (the most specific pattern, then the
most conditions). This differs from Spring whenever more than one
handler matches the pre-flight's path and announced method and all of
them have @CrossOrigin rules, whatever their specificity: the
handlers mapped to exactly that path when there are some (GET /items
producing JSON for one origin, CSV for another), else every pattern
that matches (/items/{id} and /items/** for /items/7). Spring
then allows the pre-flight from any origin, with credentials, and
leaves the check to the actual request. Here an origin the first
handler's rules do not allow gets a 403 for the pre-flight (safer: no
origin gets more than one handler's rules); give such handlers the
same CORS rules. The actual request is checked against the rules of
the handler that answers it, chosen with all its conditions, with and
without the security starter.
Every answer of a handler with CORS rules gets
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers,
a CORS request or not (no Origin, the server's own origin, an allowed
or a refused one), as Spring's DefaultCorsProcessor adds it: a cache
in between then keeps the answers to different origins apart. Each name
is added once, next to the names already in Vary (a filter's, in any
case). A handler's own Vary (of a ResponseEntity or an
ErrorResponse) adds its names to these, as Spring's
HttpEntityMethodProcessor merges them, rather than replacing them; on
a handler without CORS rules it is written as it is. With the security
starter (its CORS filter is on by default),
the filter adds them to every request with CORS rules, before the
security checks, so its own 401 and 403 answers have them too. Answers
of handlers without rules get none.
The server
The webServerFactory bean is an IoWebServerFactory (dart:io's
HttpServer, from package:spring_boot_starter_web/io.dart),
configured from the server.* properties:
server.port=8080 # 0: a free port
server.address=127.0.0.1 # all interfaces by default (127.0.0.1 in Flutter apps)
server.shutdown=graceful # or immediate
spring.lifecycle.timeout-per-shutdown-phase=20s
server.context-path=/shop # a prefix of every mapping
server.max-http-request-body-size=10MB # 413 above (10MB by default), whatever the type; -1: no limit
server.connection-timeout=60s # a body that sends nothing this long: 408; idle connections close; 0 or -1: no limit
server.request-timeout=30s # filters and handler must answer within this: else 503; no limit by default (see below)
server.compression.enabled=true # gzip, for clients that accept it
server.compression.mime-types=text/html,application/json # Spring Boot's list by default
server.compression.min-response-size=2KB # the default
server.websocket.max-text-message-size=64KB # 8KB by default; -1: no limit
server.websocket.send-buffer-size-limit=1MB # 512KB by default: more waiting drops the connection
server.websocket.send-time-limit=20s # 10s by default
server.ssl.certificate=/etc/shop/cert.pem
server.ssl.certificate-private-key=/etc/shop/key.pem
Every property, with YAML next to it, is in the configuration reference.
A deadline for every request: server.request-timeout
By default a request takes as long as its handler does. With
server.request-timeout=30s the filters and the handler must answer
within 30 seconds: when the time passes before the answer starts, the
client gets a 503 Service Unavailable problem (an
AsyncRequestTimeoutException, with the generic detail "The request did
not complete in time." and no word of the limit), and whatever the handler
does to the response afterwards is dropped, with no second answer and no
unhandled error. 0 or a negative value means no limit, which is the
default.
The request runs under a RequestDeadline of that length, which the code
it calls can read through RequestDeadline.current (remaining,
isExpired, check()), and which the Feign starter honours: a call a
controller makes through a Feign client stops at the request's deadline, so
it never outlives the request (see the Feign starter's
call-timeout).
@GetMapping('/report')
Future<Report> report() async {
final sales = await sales.totals(); // a Feign client, limited by the request
RequestDeadline.current?.check(); // between steps: stop when nobody waits
return Report(sales, await stock.levels());
}
What it does not do: Dart cannot stop running code, so a handler that never
looks at the deadline keeps running after the 503 (its late answer is
dropped); an event stream or a streamed body is not cut (a response that
starts to stream leaves the deadline, and spring.mvc.async.request-timeout
limits an SseEmitter); a WebSocket handshake is not timed; a response
already being sent is left alone; and the time the handler spends reading a
slow upload counts (server.connection-timeout still ends a body that sends
nothing). There is no per-route override yet: the property applies to every
request.
Spring has no such limit for synchronous requests, and nothing that follows a request into the calls it makes: it is an extension of Spring for Dart.
Behind a reverse proxy
A proxy that terminates TLS (nginx, Traefik, a cloud load balancer)
passes plain HTTP: without help the server takes every request for
http://, so cookies miss Secure, redirect URIs are http:// and the
client's address is the proxy's. As in Spring Boot, set:
server.forward-headers-strategy=native # or framework; none by default
# Whose headers are believed: loopback and private networks by default.
server.forward-headers.trusted-proxies=10.0.0.0/8,192.168.1.10
ForwardedHeaderFilter then applies Forwarded (RFC 7239), or
X-Forwarded-Proto, -Host, -Port and -For, before every other
filter, and only when the request comes from a trusted proxy: a client
that reaches the server directly cannot claim to be on HTTPS or to come
from another address. The client's address is the first one from the
right of X-Forwarded-For that is not a trusted proxy. The proxy must
set these headers itself (replacing what a client sent). Spring's
ForwardedHeaderFilter believes every peer; here, as Tomcat's
RemoteIpValve, only trusted ones. X-Forwarded-Prefix is not read.
With several instances behind the proxy, route each client to the same
instance (a sticky cookie): sessions live in the memory of one process.
The cookies kept before a login are signed: give every instance the
same spring.security.cookie.secret.
-
Start and stop. The
webServerStartStopLifecyclebean starts the server when the context is ready and stops it when the context closes. It is aSmartLifecyclein the same phase as Spring Boot'sWebServerStartStopLifecycle(SmartLifecycle.defaultPhase - 2048).dart:iobinds the socket asynchronously; itsstartedfuture completes once the server listens. Then the port is published as thelocal.server.portproperty and in aWebServerInitializedEvent(@EventListener() void on(WebServerInitializedEvent e)). -
Graceful shutdown (the default): the server takes no new connections, running requests get up to
spring.lifecycle.timeout-per-shutdown-phase(30 s by default), open event streams and WebSocket sessions are closed at once (1001), then whatever is left. The same property is the timeout of the context's lifecycle processor (thelifecycleProcessorbean ofspring_boot_dart, as in Spring Boot), so the beans are destroyed only after the requests finished.server.shutdown=immediatecloses everything right away. -
A port in use fails the start, as in Spring Boot: the application waits for the server to listen before the
ApplicationStartedEvent, the runners and theApplicationReadyEvent, so none of them happen; theApplicationFailedEventis published, the context closes,runAsyncthrows thePortInUseException(fromrun, it is thrown uncaught) and the program ends with a non-zero exit code. The report:Web server failed to start. Port 8080 was already in use. Action: Identify and stop the process that is listening on port 8080, or configure this application to listen on another port (server.port; 0 picks a free one). -
No server:
spring.main.web-application-type=nonekeeps the beans (andMockMvc) but starts no server, for example for a command-line task of the same application. -
TLS with PEM files (
server.ssl.*);server.ssl.client-auth=need(orwant) asks for client certificates. HTTP/1.1 only:dart:iohas no HTTP/2. -
Compression (
server.compression.enabled=true) gzips the responses ofserver.compression.mime-types(Spring Boot's list: HTML, XML, plain text, CSS, JavaScript, JSON) of at leastmin-response-size(2 KB), or of a length not known yet, for clients that sendAccept-Encoding: gzip. Event streams are compressed only whentext/event-streamis listed (each flush sends what is compressed so far). As with Tomcat, an answer with a strongETagis never compressed (the tag promises those very bytes; a weakW/"..."one is), and every answer that would be compressed for a client that takes gzip getsVary: Accept-Encoding, also when this client does not take it, so a cache keeps the two apart (once, and a*among theVarynames leavesVary: *alone). -
Bodies the handler does not read (or stops reading, at a limit), such as an upload answered with a 413 or a 415: the answer goes out first, then up to 2 MB (Tomcat's
max-swallow-size) are read and dropped, for at most 5 seconds, so the client sees the answer and the connection can serve the next request. A longer or slower body closes the connection. -
Stalled bodies and idle connections: a body that sends nothing for
server.connection-timeout(60 s, Tomcat'sconnectionTimeout) while it is read ends with a 408 problem, and the connection closes; the same timeout closes idle keep-alive connections.0or a negative value (-1) means no limit, as in Tomcat. There is no limit on the number of connections (Tomcat'smax-connections, 8192): put a proxy in front, or rely on the system's limit of open files. -
An error after the response was committed (the status and headers, maybe part of the body, are out): no error answer can follow, so the connection is dropped without finishing the response (a chunked body gets no final chunk), as Tomcat does; the client sees a broken response, not a short one that looks complete.
-
Answers without a body or a type: an answer whose media type the application did not set has no
Content-Type(dart:io's defaulttext/plain; charset=utf-8is not sent), as with Tomcat. A 204, a 304 and the 101 of a WebSocket handshake have noContent-Length(RFC 9110 forbids it on a 1xx or 204) and noTransfer-Encoding, a 304 noContent-Typeeither, so a cache that updates its stored answer with a 304's headers keeps the right type; what a handler writes to them is dropped, as Tomcat drops it. AHEADkeeps theContent-TypeandContent-Lengthof theGET. -
Host: an HTTP/1.1 request without one, a malformed one, or an absolute target (GET http://a.example/ HTTP/1.1) whose host is not theHostheader's is a 400, as Tomcat answers.dart:iokeeps the last of twoHostheaders, so such a request is served for the last one (Tomcat answers 400). -
%escapes: one that does not decode to UTF-8 (/%FF,?id=%FF) is a 400. A%without two hex digits after it is read as a plain%bydart:io(/a%G1is the path/a%25G1), where Tomcat answers 400; it can never decode to a separator. -
Content-LengthandTransfer-Encodingtogether:dart:ioaccepts such a request and reads the body as chunked, where RFC 9112 asks a server to refuse it (Tomcat answers 400). Behind a proxy that reads it the other way, requests can be smuggled: use a proxy that refuses these requests (most do), or none.
To change the settings in code, declare a WebServerFactoryCustomizer
bean. To replace the server entirely, declare your own
WebServerFactory bean; the default one then backs off
(@ConditionalOnMissingBean):
@Component()
class PortCustomizer implements WebServerFactoryCustomizer {
@override
void customize(ConfigurableWebServerFactory factory) =>
factory.settings = factory.settings.copyWith(port: 9090);
}
@Configuration()
class ServerConfig {
@Bean()
WebServerFactory webServerFactory() => MyServerFactory(); // getWebServer(HttpHandler)
}
Everything above the server (routing, binding, filters, errors) works on
ServerHttpRequest and ServerHttpResponse from spring_web_dart and
never imports dart:io. Another server therefore needs only a
WebServer and its factory, and MockMvc is one more caller of the
same HttpHandler.
Handler mappings
The dispatcherHandler bean plays the role of Spring's
DispatcherServlet: it runs the filters, then asks every
HandlerMapping bean in order for a handler. The controllers come at
order 0, the WebSocket endpoints of spring_boot_starter_websocket at
order 1. A starter or the application adds its own by declaring a
HandlerMapping bean:
// A health check answered without a controller.
@Component()
@Order(10)
class HealthMapping implements HandlerMapping {
@override
HttpHandler? getHandler(ServerHttpRequest request) => request.path == '/health'
? FunctionHttpHandler((request, response) => response.send(200, 'UP'))
: null;
}
Flutter
The starter works in a Flutter app on Android, iOS, macOS, Windows and
Linux. It runs in the main isolate with the same beans as the widgets.
HttpServer's I/O is asynchronous and does not block the UI, but heavy
synchronous work in a handler does: move it into Isolate.run.
127.0.0.1by default: an app opens no port to the network unlessserver.addresssays so.- In the background the context pauses: the server stops and frees
the port, and starts again when the app comes back
(
spring.flutter.lifecycle.pause-in-background). - Platform permissions: macOS needs the
com.apple.security.network.serverentitlement; Android needsINTERNETin the release manifest; on iOS, binding beyond localhost asks for local network access (NSLocalNetworkUsageDescription). - The web has no server sockets: the application fails at startup
with an error that says so. Use the clients (Feign, WebSocket) there,
or set
spring.main.web-application-type=none.
WebSocket endpoints
With spring_boot_starter_websocket
in the dependencies too, @WebSocketEndpoint beans and
WebSocketConfigurer handlers are served on the same port, and their
handshakes pass through the same filters:
@WebSocketEndpoint('/ws/rooms/{room}')
class ChatEndpoint {
// Each message is answered with an acknowledgement, sent back as JSON.
@OnMessage()
Ack message(@PathVariable() String room, ChatMessage message) => Ack(room);
}
The WebSocket starter's
Server endpoints
section describes them. The server reads at most 8 KB per message
(Tomcat's default in Spring): a larger text or binary message is not
read, and the session is closed with 1009 (message too big). Raise the
limits with server.websocket.max-text-message-size and
server.websocket.max-binary-message-size (this starter's keys; Spring
sets them on the container). The text limit counts UTF-8 bytes, where
Tomcat counts characters. No extension is negotiated, so messages are
never compressed (permessage-deflate).
What a session sends waits in memory until the client takes it. As
Spring's ConcurrentWebSocketSessionDecorator does, a session gives
up when more than server.websocket.send-buffer-size-limit (512 KB)
waits, or when what was sent has not left within
server.websocket.send-time-limit (10 s): the connection is dropped
(a close frame could not get through either), and the handler's
afterConnectionClosed gets 4500 (CloseStatus.sessionNotReliable)
with the reason. The pongs that answer pings wait with the messages, so
a client that sends pings and reads nothing reaches the limit too. The
session goes on reading the client while messages wait, so a client
that sends a lot while the server does too is not held up. A burst
larger than the limit that the network cannot take at once drops the
session too, as in Spring: raise the limit for such bursts, or await
flush() on the session (IoWebSocketSession, the delegate of a
DefaultServerWebSocketSession) after each message to send at the
client's pace. A client that goes away while messages wait (a reset)
ends the session with 1006 (CloseStatus.noCloseFrame) and the error
as the reason, also over TLS, where the input often ends before the
write fails (the session waits for the write, a second at most); after
close(status) from the server, the handler gets that status.
Testing
spring_boot_starter_test calls the controllers in memory with
MockMvc: no socket, but the same dispatcher, filters and error
handling. It can also start a real server on a free port.
import 'package:spring_boot_starter_test/spring_boot_starter_test.dart';
import 'package:test/test.dart';
import '../bin/main.spring.dart';
void main() {
final app = springBootTest(springApplication); // WebEnvironment.mock: no server
test('finds a book', () async {
await app.mockMvc.get('/api/books/1111').andExpect(
status: 200,
contentType: MediaType.applicationJson,
json: {'isbn': '1111', 'title': 'Dune'},
);
});
test('rejects an invalid book', () async {
await app.mockMvc
.post('/api/books', json: {'isbn': '', 'title': ''})
.andExpect(status: 400, contentType: MediaType.applicationProblemJson);
});
test('streams changes', () async {
final result = await app.mockMvc.get('/api/books/changes').andReturn();
// Skip the leading ':' comment line: wait for the first real event.
final next = result.response.eventStream.firstWhere((e) => e.data != null);
await app.mockMvc.post('/api/books', json: {'isbn': '5555', 'title': 'Vurt'});
expect((await next).id, '5555');
await result.cancel();
});
}
springBootTest(springApplication, webEnvironment: WebEnvironment.randomPort)
starts the real server on a free port (app.localServerPort), for a
Feign client or dart:io's HttpClient to call. In a test class:
@SpringBootTest(webEnvironment: ...), @Autowired() late MockMvc mvc;,
@LocalServerPort() late int port;. Web tests are described in full in
docs/testing.md.
ApplicationContextRunner starts no server unless the test sets
spring.test.web-environment=random-port.
How it works
@GetMapping, @PostMapping, ..., @ExceptionHandler are marked
@BeanMethodAnnotation() (from spring_core_dart). For a bean with
such methods, the generator writes a description of each one into the
bean definition (BeanDefinition.annotatedMethods: the parameters with
their names, types and annotations, the result type, and an invoker). It
also introspects the parameter and result types of the bean's package
for JSON. At startup, RequestMappingHandlerMapping reads these
descriptions, checks them and builds the routes. Handlers are called on
the bean as the context holds it, so AOP advice on a controller
(@Cacheable, method validation, the security starter's
@PreAuthorize) applies.
Differences from Spring
server.request-timeoutandRequestDeadlineare extensions: Spring times out connections (Tomcat) and asynchronous requests (spring.mvc.async.request-timeout), not the handling of a synchronous request, and no deadline follows a request into the calls it makes. See A deadline for every request.- Renamed properties:
server.servlet.context-pathandserver.servlet.session.*areserver.context-pathandserver.session.*, andspring.servlet.multipart.*isspring.web.multipart.*: there is no servlet container. The old names stop the start and name the new ones. - Errors are
ProblemDetail(application/problem+json), except the plain-text answers Spring gives too (a refused CORS request or WebSocket handshake). Spring Boot's default is the/errorJSON (timestamp,status,error,path) unlessspring.mvc.problemdetails.enabled=true; here there is no/errorendpoint and noErrorController. @ControllerAdvice.of(basePackages: ...)takes library URI prefixes (package:shop/admin/) instead of Java packages, andannotationsmatch a controller's annotation by its exact type; there is nobasePackageClasses.@ExceptionHandler([types])matches exact runtime types, because Dart cannot test whether aTypeis a subtype of another. Subtypes match through the type of the exception parameter (@ExceptionHandler() void on(StateError e)).- A
HandlerExceptionResolverreturns anErrorResponse(ornull) instead of writing into the response and returning aModelAndView; Spring's built-in resolvers are the@ExceptionHandlermethods andResponseStatusExceptionhandling, and a resolver bean is asked after them. @ResponseStatusgoes on handler methods or the controller class, not on exception classes, whose annotations cannot be read at runtime: throw aResponseStatusExceptionor implementErrorResponseinstead.- A pre-flight that several handlers match (with
@CrossOrigineach; no handler of exactly that path, or several) gets the first one's CORS rules; Spring allows it from any origin with credentials, whatever the handlers' specificity (see CORS). - CORS values outside visible US-ASCII are refused with a 403: an
Origin, or a pre-flight's requested method or headers, with a space, a control character or a letter beyond ASCII, even where*allows any. Spring echoes such a method or header (no browser sends one), and refuses such anOrigintoo. - An
Originlonger than 1024 characters is never allowed (CORS and WebSocket handshakes: a 403), whatever the rules,*included; a real origin is under 300 characters. Spring checks any length, and its patterns' regular expressions slow down with it. - Custom argument resolvers are asked before the built-in ones
(Spring asks them after), so a starter can take over any parameter,
as the security starter's
@AuthenticationPrincipaldoes. - No
@ModelAttributeand no binding of forms to objects. Form fields reach a handler through@RequestParam(one field, a list, or all of them as aMap) or a@RequestBody() Map<String, String>. - No
HttpSessionparameter: a handler gets the session withrequest.getSession(). The session cookie isSameSite=Laxand the number of sessions is limited (server.session.max-sessions; at the limit, expired ones are removed, then a new session is refused with503). - Multipart without a servlet container: the
spring.web.multipart.*keys are Boot'sspring.servlet.multipart.*without the servlet, and the temporary files are this starter's own (file-size-thresholdis0B: every file goes to disk). Onlymultipart/form-dataparts are bound, notmultipart/mixed, for@RequestPart. - A declared
Stream<Uint8List>orStream<List<int>>result is raw bytes. Spring MVC has no byte stream result; Spring WebFlux writes aFlux<DataBuffer>the same way. Other streams are server-sent events. - Resources: one byte range per request. Several ranges get the
whole content, not
multipart/byteranges, and resources have noETag. - Event streams start with a
:comment line, becausedart:iosends the headers with the first body bytes. - An
SseEmitterwithout a timeout never times out whenspring.mvc.async.request-timeoutis not set: there is no servlet container whose default applies (Tomcat's 30 seconds in Spring). It ends when it is completed, when the client goes away, or when the server stops. Give it a timeout, or set the property. - WebSocket message limits and compression are the server's:
server.websocket.max-text-message-sizeandmax-binary-message-size(8 KB each, Tomcat's defaults) are this starter's keys; Spring sets them on the container (ServletServerContainerFactoryBean). The text limit counts UTF-8 bytes; Tomcat counts characters. permessage-deflate is not offered, while Tomcat negotiates it. - Every WebSocket session has send limits
(
server.websocket.send-buffer-size-limit, 512 KB, andsend-time-limit, 10 s; past them the connection is dropped with 4500), Spring'sConcurrentWebSocketSessionDecoratordefaults. In Spring they apply to STOMP sessions and to sessions you decorate; a plain handler on Tomcat blocks the sending thread instead (up to Tomcat's 20 s blocking send timeout). AwaitIoWebSocketSession.flush()aftersendMessageto wait the same way. - Stalled request bodies get a 408 after
server.connection-timeout(60 s); with Tomcat the read fails after itsconnectionTimeoutand Spring answers 400. There is no connection limit (Tomcat'smax-connections). TwoHostheaders and a%without two hex digits are accepted (dart:io), where Tomcat answers 400. - A raw
\in the request target is a/:dart:ioparses the target before the server sees it and keeps no raw form, so/files/..\..\secretarrives as/secret(its..resolved), and raw bytes past ASCII are read as Latin-1 (/cafésent as UTF-8 arrives as/caf%C3%83%C2%A9). Tomcat answers 400 to both. Filters, security rules and controllers all see the same path, but a proxy in front whose rules look at paths must refuse a\itself. - HTTP/1.1 only;
server.http2.enabledis not read. - TLS from PEM file paths, not
classpath:locations; no keystores or SSL bundles. - More startup checks than Spring: a path variable without a
parameter, a parameter nothing can fill, an invalid
@ExceptionHandler. server.max-http-request-body-size,spring.mvc.sse.heartbeat-interval,spring.web.multipart.max-part-count(Boot'sserver.tomcat.max-part-count),server.connection-timeout(Boot 2.0's key; Boot 4's isserver.tomcat.connection-timeout) andserver.websocket.*are this starter's own properties.MockMvctakes named expectations (andExpect(status: 200, json: ...), matchers allowed) instead ofstatus().isOk()andjsonPath(...).
Not yet
@ConditionalOnWebApplication, test slices (@WebMvcTest).ResponseBodyEmitter(aStream<T>result covers SSE and NDJSON).- Several byte ranges as
multipart/byteranges,ETagfor resources,@RequestPartwithmultipart/mixed. - Static files (
lib/resources/static/), forwarded headers (server.forward-headers-strategy),spring.web.cors.*,server.max-http-request-header-size. - Flutter: a warning when a handler blocks the main isolate, decoding
big JSON bodies in another isolate, the whole server in a background
isolate (
server.isolate: background). - Other servers (
shelf, Rust over FFI), STOMP, the actuator over HTTP. See the roadmap.
Libraries
- io
- The
dart:ioHTTP server of the web starter, which runs on the VM and in Flutter apps on mobile and desktop. - spring_auto_configuration
- The beans the web starter adds to an application: the server, the dispatcher, the controller mapping, sessions and uploads.
- spring_boot_starter_web
- An HTTP server for a Spring application: classes marked
@RestControlleranswer requests, one method per mapping.