spring_web_dart 0.2.1
spring_web_dart: ^0.2.1 copied to clipboard
Spring Web for Dart: @GetMapping, @PathVariable, @RequestBody, ResponseEntity, server-sent events and HTTP/WebSocket client transports for the VM, Flutter and the web.
spring_web_dart #
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.
Spring's spring-web for Dart: the request mapping annotations
(@GetMapping, @PostMapping, @PathVariable, @RequestParam,
@RequestHeader, @CookieValue, @RequestBody), HTTP types
(ResponseEntity, HttpHeaders, HttpStatus, HttpMethod, MediaType),
HTTP and
WebSocket client transports for the VM, Flutter and the web, and
server-sent events (EventStream<T>). Only spring_core_dart as a
dependency.
The annotations are read by Feign clients
(spring_boot_starter_feign):
an annotated interface becomes an injectable HTTP client. WebSocket
interfaces (@WebSocketClient) come from
spring_boot_starter_websocket.
The coming web server will use the same annotations on controllers.
dependencies:
spring_web_dart: ^0.2.1
Annotations #
@FeignClient('catalog', url: r'${catalog.url}') // from spring_boot_starter_feign
abstract interface class CatalogClient {
@GetMapping('/books/{isbn}')
Future<Book> find(@PathVariable('isbn') String isbn);
@GetMapping('/books')
Future<List<Book>> search(@RequestParam('q') String query, {@RequestParam() int page = 0});
@GetMapping.of(path: '/books', headers: ['X-Api-Version=2'])
Future<List<Book>> filter(@RequestParam() Map<String, Object?> filters); // ?author=...&year=...
@PostMapping.of(path: '/books', consumes: MediaType.applicationJson)
Future<ResponseEntity<Book>> create(
@RequestBody() Book book,
@RequestHeader('X-Request-Id') String requestId,
);
@GetMapping() // no path: the base path of the client
Future<List<Book>> all(@RequestParam.of(name: 'sort', defaultValue: 'title') String? sort);
@DeleteMapping('/books/{isbn}')
Future<void> delete(@PathVariable() String isbn); // {isbn}: the parameter name
}
| Annotation | On | Meaning |
|---|---|---|
@GetMapping([path]), @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping |
method | HTTP method and path (/books/{isbn}); no path (@GetMapping()) maps the base path of the client (or the path of the class) |
@GetMapping.of({path, headers, produces, consumes}) (also @PostMapping.of, ...) |
method | the same with every attribute by name: headers: ['Name=value'] (Spring's syntax), produces (Accept), consumes (Content-Type) |
@RequestMapping([path]), @RequestMapping.of({path, method, headers, produces, consumes}) |
method | any method (method: HttpMethod.head; GET by default) |
@PathVariable(['name']), @PathVariable.of({name, required}) |
parameter | fills {name} of the path (URL-encoded) |
@RequestParam(['name']), @RequestParam.of({name, required, defaultValue}) |
parameter | a query parameter; null is left out, a list repeats it, a Map gives one per entry |
@RequestHeader(['name']), @RequestHeader.of({name, required, defaultValue}) |
parameter | a header; null is left out, a list gives several values, a Map one header per entry |
@CookieValue(['name']) |
parameter | a cookie (Cookie: name=value; ...); null is left out |
@RequestBody({required}) |
parameter | the body (JSON) |
Without a name, @PathVariable(), @RequestParam(), @RequestHeader() and
@CookieValue() use the parameter name (the generator keeps parameter
names). All mapping annotations implement Mapping (method, path,
headers, produces, consumes; path is '' for none), so code that
reads them can use annotations.whereType<Mapping>().
required is true by default, as in Spring. A required parameter that is
null is an error naming it, a defaultValue (a String, sent as is)
replaces null and implies required: false, and a null that is not
required is left out. The parameter type counts too, as for Kotlin or
@Nullable parameters in Spring: String? q is not required whatever
required says, and a Dart default value ({int page = 0}) is simply the
argument. A path variable is required unless required: false (whatever
its type); then null gives an empty string.
HTTP types #
| Type | Spring | What |
|---|---|---|
ResponseEntity<T> |
ResponseEntity<T> |
status code, headers and body; ResponseEntity.ok(body), created(uri).body(x), notFound().build() |
HttpHeaders |
HttpHeaders |
case-insensitive, several values per name, order and casing kept; forEach((name, values)), toSingleValueMap(), contentType, accept, eTag, ifNoneMatch, lastModified, date, contentDisposition, location, contentLength, setBearerAuth, setBasicAuth; name constants HttpHeaders.contentTypeHeader, ... |
HttpStatus |
HttpStatus |
every Spring 7 constant (notFound, unprocessableContent, contentTooLarge, ...) as an int; reasonPhrase, series, is2xxSuccessful, is4xxClientError, isError, ...; valueOf(404), resolve(599) |
HttpStatusCode |
HttpStatusCode |
any code (statusCode of responses): HttpStatusCode.valueOf(599).is5xxServerError |
HttpStatusSeries |
HttpStatus.Series |
informational, successful, redirection, clientError, serverError |
HttpMethod |
HttpMethod |
get, post, ...; value is GET, parse('POST') |
MediaType |
MediaType |
applicationJson, applicationProblemJson, ...; parse, includes, isCompatibleWith |
final headers = HttpHeaders()
..contentType = MediaType.applicationJson
..setBearerAuth(token);
headers.forEach((name, values) => print('$name: ${values.join(', ')}'));
if (response.statusCode.is2xxSuccessful) { ... }
if (response.statusCode == HttpStatus.notFound) { ... }
HttpStatus.valueOf(503).reasonPhrase; // 'Service Unavailable'
HttpHeaders, HttpStatus, HttpRequest and HttpResponse share their
names with dart:io classes; in a file that needs both, import dart:io
with a prefix (import 'dart:io' as io;) or hide them.
HTTP transport #
HttpTransport sends an HttpRequest and returns an HttpResponse with
status, headers and body bytes. Every status code is a response; network
failures and timeouts throw HttpTransportException (isTimeout).
final transport = platformHttpTransport(connectTimeout: const Duration(seconds: 3));
final response = await transport.send(
HttpRequest(
method: HttpMethod.post,
url: Uri.parse('https://api.neon.books/books'),
headers: HttpHeaders({HttpHeaders.contentTypeHeader: MediaType.applicationJson}),
body: utf8.encode('{"isbn":"1"}'),
readTimeout: const Duration(seconds: 60), // each wait for data
),
);
if (response.statusCode.is2xxSuccessful) print(response.bodyText);
transport.close();
Two optional timeouts: readTimeout is the longest wait for data — for
the response, then between two chunks of the body (Spring's and Feign's
read timeout); timeout is a deadline for the whole exchange. When one
runs out the request is aborted with an HttpTransportException
(isTimeout). followRedirects: false returns a 3xx response instead of
following it.
| Transport | Platforms | Import |
|---|---|---|
platformHttpTransport() |
picks the one below | package:spring_web_dart/spring_web_dart.dart |
IoHttpTransport |
VM, Flutter Android/iOS/desktop (dart:io HttpClient) |
package:spring_web_dart/io.dart |
FetchHttpTransport |
web, Flutter web (fetch through dart:js_interop) |
package:spring_web_dart/browser.dart |
MockHttpTransport |
tests | package:spring_web_dart/testing.dart |
| your own | e.g. on Dio or package:http |
implement HttpTransport (send, stream, close) |
IoHttpTransport follows redirects as far as HttpClient does (GET and
HEAD, 303 after a POST) and aborts the request when a timeout runs
out. FetchHttpTransport follows redirects (followRedirects: false
gives the browser's opaque redirect, status 0) and aborts through an
AbortController; on the web the server must allow the request (CORS) — a
blocked request is an HttpTransportException.
Streaming responses #
transport.stream(request) returns as soon as the status and headers
arrive; the body comes as byte chunks (StreamedHttpResponse.body). The
request's timeout and readTimeout cover only the wait for the headers.
Cancelling the subscription closes the connection.
final response = await transport.stream(request);
if (!response.isSuccess) throw StateError((await response.read()).bodyText);
await for (final chunk in response.body) { ... }
Server-sent events #
A text/event-stream response is a stream of events. The type for it is
EventStream<T>: it is a Stream<T> of the data, so the usual case
needs no wrapper type; the whole events (ServerSentEvent<T> with id,
event, retry, comment) are there through .events.
@FeignClient('orders', url: r'${orders.url}')
abstract interface class OrdersClient {
@GetMapping('/orders/events')
EventStream<OrderEvent> events();
}
await for (final order in orders.events()) {
print(order.status);
}
orders.events().events.listen((e) => print('${e.id} ${e.event} ${e.data}'));
EventStream is cold: each listener opens its own connection, cancelling
(or break in await for) closes it. EventStream.fromIterable,
EventStream.of and EventStream.fromEvents build one for fallbacks and
tests.
Under the Feign method: connectServerSentEvents opens the connection and
reconnects like the browser's EventSource (SseReconnect: after the
delay or the server's retry:, sending Last-Event-ID; 204 No Content
stops), and SseDecoder parses the body following the HTML specification.
Both work on any HttpTransport, Feign or not.
Differs from Spring. Spring has no
EventStream:WebClientand@HttpExchangeinterfaces returnFlux<ServerSentEvent<T>>(orFlux<T>), and Spring Cloud OpenFeign cannot stream at all. Here the event wrapper is folded into the stream type, and reconnection is built in (in Spring it isFlux.retryWhen).
WebSocket transport #
WebSocketTransport.connect(url) opens a WebSocketSession (Spring's
name): incoming messages (String for text frames, Uint8List for
binary), sendMessage, acceptedProtocol, isOpen, close([status]) and
closeStatus, with CloseStatus and Spring's constants
(CloseStatus.normal, goingAway, policyViolation, ...;
withReason, equalsCode). The declarative client on top is
@WebSocketClient in spring_boot_starter_websocket.
final sockets = platformWebSocketTransport();
final session = await sockets.connect(
Uri.parse('wss://chat.neon.books/ws'),
headers: HttpHeaders({'Authorization': 'Bearer $token'}),
timeout: const Duration(seconds: 5),
);
session.messages.listen(print);
session.sendMessage('{"text":"hi"}');
await session.close(); // CloseStatus.normal; or e.g.
// await session.close(CloseStatus.policyViolation.withReason('no token'));
| Transport | Platforms | Import |
|---|---|---|
platformWebSocketTransport() |
picks the one below | package:spring_web_dart/spring_web_dart.dart |
IoWebSocketTransport |
VM, Flutter Android/iOS/desktop (dart:io WebSocket) |
package:spring_web_dart/io.dart |
BrowserWebSocketTransport |
web, Flutter web (WebSocket through dart:js_interop) |
package:spring_web_dart/browser.dart |
MockWebSocketTransport |
tests | package:spring_web_dart/testing.dart |
Browsers send no custom headers with the WebSocket handshake:
BrowserWebSocketTransport fails when headers is not empty, so pass a
token as a query parameter or a protocol on the web.
Testing: MockHttpTransport #
Prepared answers instead of a server, and a record of what was sent (like
Spring's MockRestServiceServer):
import 'package:spring_web_dart/testing.dart';
final transport = MockHttpTransport();
transport.when(HttpMethod.get, '/books/1').respond(200, json: {'isbn': '1', 'title': 'Dune'});
transport.when(HttpMethod.get, '/books?q=dune').respond(200, json: []); // query must match
transport.when(HttpMethod.get, 'https://api.neon.books/stock').respond(503);
transport.when(HttpMethod.get, '/flaky').fail(HttpTransportException('down'));
transport.when(HttpMethod.get, '/flaky').respond(200, body: 'up'); // second call
transport
.when(HttpMethod.post, '/books')
.respondWith((request) => HttpResponse(201, body: request.body!, request: request));
// In a Spring Boot test the Feign clients then talk to the mock:
final app = springBootTest(springApplication, overrides: [BeanOverride<HttpTransport>(transport)]);
expect(transport.lastRequest.headers['Accept'], 'application/json');
expect(transport.requests, hasLength(2));
Several when for the same request answer in order, the last one repeats.
A request nobody expected throws a StateError that lists the expected
ones.
Event streams: respondEvents([...]) answers with a text/event-stream
body (data that is not a String is written as JSON; keepOpen: true
keeps the response open), respondStream(status, () => chunks) with any
streamed body.
transport
..when(HttpMethod.get, '/orders/events').respondEvents([
ServerSentEvent({'id': 1, 'status': 'NEW'}, id: '1'),
])
..when(HttpMethod.get, '/orders/events').respond(204); // the reconnect stops
Testing: MockWebSocketTransport #
Plays the server of every connection:
final sockets = MockWebSocketTransport();
// In a Spring Boot test: withBean<WebSocketTransport>(sockets)
final server = await sockets.nextConnection; // waits for the client to connect
server.pushJson({'from': 'ann', 'text': 'hi'}); // server → client
expect(server.sentJson, [{'text': 'hello'}]); // what the client sent
server.drop(); // connection lost (1006)
sockets.failNextConnect(); // the next connect fails
Differences from Spring #
- Mapping
headersuse Spring's'Name=value'. A client sends them; entries that are only conditions for a server ('Name','!Name','Name!=value') are ignored, as Spring Cloud OpenFeign does. Feign's'Name: value'is rejected at startup. - Attributes next to a positional
pathornameare written with the.ofconstructor: Dart cannot combine an optional positional parameter with named ones, so@GetMapping('/books', produces: ...)is@GetMapping.of(path: '/books', produces: ...)and@RequestParam('page', required: false)is@RequestParam.of(name: 'page', required: false).@GetMapping()and@GetMapping('/books')stay as in Spring.producesandconsumestake one media type, not a list. @CookieValuehas norequired:/defaultValue:: anullcookie is left out.@RequestBody(required:)exists.HttpStatusconstants areints (an extension type), not enum values: they compare with status codes directly,toString()is the number (noname()),100 ContinueisHttpStatus.continue_(as indart:io), and the deprecated aliases (PAYLOAD_TOO_LARGE,UNPROCESSABLE_ENTITY) are left out. ThestatusCodeof responses is anHttpStatusCode(also anint).HttpHeadersname constants end inHeader(HttpHeaders.contentTypeHeader, as indart:io), because the typed accessors use the plain names (headers.contentType);contentTypeis aString,acceptaList<MediaType>,lastModified/dateareDateTime?(nullinstead of-1) andcontentDispositionisnullwhen there is none (Spring: an empty one).operator []gives the first value,getAllall of them, andtoMap()joins several values with,(toSingleValueMap()keeps the first).MediaTypeconstants areStrings (Spring's..._VALUEconstants), so they work in annotations;MediaType.parsegives aMediaTypeobject (Spring'sparseMediaType) withincludes,isCompatibleWith,charset,qualityValue. Parameters follow Spring's rules (tokens or quoted values, a parameter without=ignored, the last of a repeated name kept), but acharsetonly has to be a legal name: Dart knows few encodings (UTF-8, ISO-8859-1, US-ASCII), socharsetisnullfor the others instead of an "unsupported charset" error, andHttpResponse.bodyTextfalls back to UTF-8.ResponseEntitybuilders end inbody(...)orbuild<T>();ResponseEntity.ok(body)is a constructor (Spring'sok()builder isResponseEntity.status(HttpStatus.ok)).ResponseEntity.oftakes a nullable body (Dart has noOptional), likeofNullable.==compares lists, sets and maps in the body by content.HttpHeaders.addAll(name, values)adds the values of one name;addHeaders(other)is Spring'saddAll(HttpHeaders)andputAll(other)itsputAll(HttpHeaders)(Dart has no overloads).sizecounts names.HttpMethod.parseis case-sensitive like Spring'sHttpMethod.valueOf, but throws on any other text,'get'included (Spring'svalueOfcreates a new method).- Timeouts: an
HttpRequesthas areadTimeout(the longest wait for data, Spring's read timeout) and atimeout(a deadline for the whole exchange, which Spring's request factories do not have). WebSocketSessionhands out the incoming messages as aStream(Spring calls aWebSocketHandler),sendMessagetakes aString(text) or aList<int>(binary) instead ofTextMessage/BinaryMessage, andcloseStatustells how it closed (Spring passes it toafterConnectionClosed).