spring_boot_starter_feign 0.4.0
spring_boot_starter_feign: ^0.4.0 copied to clipboard
Feign clients for spring_boot_dart: annotate an interface with @FeignClient and @GetMapping, inject it as a bean; JSON via ObjectMapper, interceptors, retries, fallbacks.
spring_boot_starter_feign #
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.
Feign clients for spring_boot_dart
applications, like Spring Cloud OpenFeign: describe an HTTP API as an
abstract class with @GetMapping, @PostMapping, ... and inject it like
any bean. The generator writes the implementation (no reflection), JSON
goes through the ObjectMapper of spring_core_dart.
@FeignClient('catalog', url: r'${catalog.url}')
abstract interface class CatalogClient {
@GetMapping('/books/{isbn}')
Future<Book> find(@PathVariable() String isbn);
@GetMapping('/books')
Future<List<Book>> search(@RequestParam('q') String query, {@RequestParam() int? page});
@PostMapping('/books')
Future<ResponseEntity<Book>> create(@RequestBody() Book book);
@DeleteMapping('/books/{isbn}')
Future<void> delete(@PathVariable() String isbn);
}
@Introspected() // JSON without fromJson / toJson
class Book {
const Book({required this.isbn, required this.title});
final String isbn;
final String title;
}
@Service()
class Shop {
Shop(this.catalog); // injected like any bean
final CatalogClient catalog;
Future<String> title(String isbn) async => (await catalog.find(isbn)).title;
}
# lib/resources/application.properties
catalog.url=https://api.neon.books
Setup #
dependencies:
spring_boot_starter_feign: ^0.4.0
dev_dependencies:
build_runner: ^2.17.0
spring_core_dart_generator: ^0.4.0 # required: writes the client implementations
dart run build_runner build
The starter is found automatically (no @EnableFeignClients): every
abstract class with @FeignClient in the scanned package becomes a bean,
named after the interface (catalogClient). One import is enough:
package:spring_boot_starter_feign/spring_boot_starter_feign.dart
re-exports spring_boot_dart, spring_web_dart (the mapping
annotations) and spring_boot_starter_logging. The starter is the only
dependency an application needs: it brings the others, logging included,
like every Spring Boot starter.
Classes used by the clients (parameters and results) are introspected for JSON automatically when they are in the same package.
Methods #
Every method needs one mapping annotation and must return a Future —
or an EventStream<T> for server-sent events (see below).
| Annotation | Request |
|---|---|
@GetMapping('/books/{isbn}') |
GET |
@PostMapping('/books') |
POST |
@PutMapping, @PatchMapping, @DeleteMapping |
PUT, PATCH, DELETE |
@RequestMapping.of(path: '/x', method: HttpMethod.head) |
any method (GET by default) |
The path is joined to the client's url and path; without a path
(@GetMapping()) the request goes to the client's url and path itself
(@FeignClient(url: 'https://api.shop.test', path: '/api') →
https://api.shop.test/api). A query in the path
(@GetMapping('/books?sort=title')) is sent as written, the parameters of
the call appended to it. Mapping annotations
also take headers: ['X-Shop=${shop.name}'] (Spring's Name=value,
placeholders allowed; server conditions such as '!X-Debug' are ignored,
Feign's 'Name: value' stops the application), produces (the Accept
header, application/json by default) and consumes (the Content-Type
of the body). With a path they are written with the .of constructor, as
Dart cannot combine an optional positional parameter with named ones:
@GetMapping.of(path: '/books', headers: ['X-Shop=neon']).
Parameters:
| Parameter | Goes to |
|---|---|
@PathVariable() String isbn |
{isbn} in the path (URL-encoded, a / stays a /: see decode-slash); @PathVariable('id') for another name |
@RequestParam('q') String query |
?q=...; null is left out, a List repeats the parameter |
@RequestParam() Map<String, Object?> filters |
one query parameter per entry (?author=...&year=...; a List value repeats it, null is left out) |
@RequestHeader('X-Trace') String? trace |
a header; null is left out, a List gives several values |
@RequestHeader() Map<String, String> headers |
one header per entry |
@CookieValue('SESSION') String session |
a cookie: the cookies of a call go in one Cookie: a=1; b=2 header; null is left out |
@RequestBody() Book book |
the body: objects as JSON (ObjectMapper), String as text, Uint8List as bytes; with consumes: MediaType.applicationFormUrlencoded (or that Content-Type header) a Map or object is sent as a form (name=value&..., see below) |
@RequestPart('file') MultipartFile file |
a part of a multipart/form-data body (files, text fields, JSON parts; see Files and multipart) |
| a parameter without annotation | the body (only one such parameter) |
@PathVariable, @RequestParam and @RequestHeader follow Spring's
required (default true) and defaultValue (a String), written with
the .of constructor: @RequestParam.of(name: 'sort', defaultValue: 'asc') String? sort.
Argument null |
Result |
|---|---|
with defaultValue |
the default value is sent |
| required (the default, for a non-nullable parameter) | an ArgumentError naming the parameter and the annotation value, before anything is sent |
not required (required: false, or a nullable parameter) |
the query parameter or header is left out; a path variable (required unless required: false, whatever its type) becomes an empty string |
As Spring does for Kotlin and @Nullable parameters, a nullable Dart
query parameter or header (String? q) is never required, whatever
required says; use a non-nullable parameter to require a value.
Results:
| Return type | From the response |
|---|---|
Future<Book>, Future<List<Book>>, Future<Map<String, int>> |
the JSON body, through the ObjectMapper |
Future<Book?> |
null for an empty body (or a 404 with dismiss404) |
Future<void> |
nothing (the status must be 2xx) |
Future<String>, Future<Uint8List> |
the body as text, as bytes |
Future<ResponseEntity<Book>> |
status, headers and the body (read-only headers) |
Future<ResponseEntity<void>> |
status and headers; the body is ignored |
Future<HttpResponse> |
the raw response (Feign's Response): any status, no error decoding, no dismiss404 |
EventStream<OrderEvent>, Stream<OrderEvent> |
the events of a text/event-stream response (see Server-sent events) |
Form bodies work like Spring Cloud OpenFeign's SpringFormEncoder: the
entries of a Map (or the properties of an object, as the ObjectMapper
writes them) become name=value pairs, URL-encoded in the charset of
the content type (UTF-8 by default); a List value repeats the name and
null values are left out.
@PostMapping.of(path: '/login', consumes: MediaType.applicationFormUrlencoded)
Future<void> login(@RequestBody() Map<String, String> form);
// username=neo&password=s3cr%3Ft
Enums, DateTime and other values in paths, query parameters, headers and
cookies are written like in JSON (an enum's name or @JsonValue, ISO-8601
dates); they must be single values (or lists of them). An object there —
a class the ObjectMapper writes as a JSON object, e.g. @RequestParam() Book book — stops the application at startup; pass its fields as
parameters or a Map instead (a value whose type only tells at runtime,
such as Object, fails the call with an EncodeException).
Every client is checked when it is created: a {variable} without a
@PathVariable parameter (or the other way round), a method without a
mapping, a synchronous method (other than an event stream), an object as
a parameter value or a header in Feign's syntax stops the application at
startup with the reason, for instance:
Invalid Feign client CatalogClient:
- CatalogClient.find: the path variable {isbn} of "/books/{isbn}" has no @PathVariable parameter
Files and multipart #
A method with @RequestPart parameters sends a multipart/form-data
body, like Spring Cloud OpenFeign with @RequestPart / MultipartFile
(consumes: MediaType.multipartFormData is optional; another consumes
stops the application at startup):
@PostMapping.of(path: '/books/{isbn}/files', consumes: MediaType.multipartFormData)
Future<UploadResult> upload(
@PathVariable() String isbn,
@RequestPart('file') MultipartFile file, // a file part
@RequestPart('meta') FileMeta meta, // an object → a JSON part
@RequestPart('note') String note, // a text field
@RequestParam('draft') bool draft, // still a query parameter: ?draft=true
);
await books.upload(
'978-0',
InMemoryMultipartFile('file', bytes, originalFilename: 'cover.png'),
FileMeta(title: 'Cover'),
'first edition',
false,
);
@RequestPart('x') parameter |
Part |
|---|---|
MultipartFile |
a file: filename and content type from the file (else from the file name, else application/octet-stream); the part is named after the annotation, not file.name |
List<MultipartFile> |
one file part per file, all named x |
Uint8List, List<int> |
a file part without file name (application/octet-stream) |
String, numbers, bool, enums, DateTime (or a List of them) |
a text field (one per item), written like query parameters |
an object, a Map, a List of objects |
a JSON part (application/json), written with the client's ObjectMapper |
MultipartFile is the type controllers receive (from spring_web_dart);
InMemoryMultipartFile builds one from bytes or text (and a received file
can be passed on as it is). @RequestPart.of(name: 'note', required: false), or a nullable parameter, leaves the part out for null; a
required part that is null is an ArgumentError, before anything is
sent. @RequestParam, @RequestHeader and @PathVariable work as on any
method — @RequestParam stays in the query, like Spring Cloud OpenFeign.
The Content-Type is multipart/form-data; boundary=... (it replaces
consumes). At startup, @RequestPart together with @RequestBody, or a
parameter with @RequestPart and another annotation, stops the
application.
The body is built in memory once per call (each file read once), so retries send the same bytes; there is no streaming upload yet.
Server-sent events #
A method returning EventStream<T> reads a text/event-stream response.
EventStream<T> is a Stream<T> of the decoded data, so the type stays
short; the whole events (with id, event, retry) are there through
.events when you need them:
@FeignClient('orders', url: r'${orders.url}')
abstract interface class OrdersClient {
@GetMapping('/shops/{shop}/orders/events')
EventStream<OrderEvent> events(@PathVariable() String shop);
}
// the data:
await for (final order in orders.events('neon')) {
print(order.status);
}
// the whole events:
orders.events('neon').events.listen((e) {
print('${e.id} ${e.event}: ${e.data.status}');
});
- Each event's data is read like a JSON body (through the
ObjectMapper);EventStream<String>gives the data as sent. An event that cannot be read is an error on the stream (aDecodeException), the stream goes on. - The stream is cold: every listener opens its own connection, cancelling the
subscription (or
breakinawait for) closes it. Accept: text/event-streamandCache-Control: no-cacheare sent (produceschangesAccept); interceptors run for every connection, so a refreshed token is used on reconnect.read-timeoutcovers the wait for the response headers only (a stream may stay quiet as long as it likes).Stream<T>works as a return type too (you get anEventStream<T>).Future<EventStream<T>>is rejected at startup: return the stream directly, it connects when listened to.
Reconnection works like the browser's EventSource: when the response ends
or the connection breaks, the client connects again after a delay (the
server's retry: wins), sending the last event id in Last-Event-ID. A
204 No Content ends the stream; an error status ends it with the
FeignException of the ErrorDecoder (a RetryableException reconnects).
spring.cloud.openfeign.client.config.orders.event-stream.reconnect=true
spring.cloud.openfeign.client.config.orders.event-stream.reconnect-delay=3s
spring.cloud.openfeign.client.config.orders.event-stream.max-attempts=10
| Property | Meaning |
|---|---|
event-stream.reconnect |
connect again after the connection ended or broke (default true) |
event-stream.reconnect-delay |
the wait before connecting again, until the server sends retry: (default 3s) |
event-stream.max-attempts |
connections in a row without an event before the stream gives up (default: no limit) |
With a fallback, the fallback's stream takes over when the stream fails
before its first event; later failures reach the listener.
In tests, MockHttpTransport answers with events:
server
..when(HttpMethod.get, '/shops/neon/orders/events').respondEvents([
ServerSentEvent({'id': 1, 'status': 'NEW'}, id: '1'),
])
..when(HttpMethod.get, '/shops/neon/orders/events').respond(204); // then stop
This differs from Spring. Spring Cloud OpenFeign cannot stream at all;
Spring's own answer is Flux<ServerSentEvent<OrderEvent>> (or
Flux<OrderEvent>) from WebClient or an @HttpExchange interface, with
reconnection left to retryWhen. Here EventStream<T> folds the event
wrapper into the stream, reconnects by itself, and the event-stream.*
properties are additions of this starter.
Configuration #
Properties per client name (@FeignClient('catalog')), with default
for all clients, like Spring Cloud OpenFeign:
spring.cloud.openfeign.client.config.default.read-timeout=10s
spring.cloud.openfeign.client.config.default.connect-timeout=2s
spring.cloud.openfeign.client.config.catalog.url=https://api.neon.books
spring.cloud.openfeign.client.config.catalog.logger-level=basic
spring.cloud.openfeign.client.config.catalog.dismiss404=true
spring.cloud.openfeign.client.config.catalog.default-request-headers.X-Shop=neon
spring.cloud.openfeign.client.config.catalog.default-query-parameters.lang=en
spring.cloud.openfeign.client.config.catalog.follow-redirects=false
spring.cloud.openfeign.client.config.catalog.retryer=catalogRetryer
spring.cloud.openfeign.client.config.catalog.error-decoder=com.example.ShopErrors
spring.cloud.openfeign.client.config.catalog.request-interceptors[0]=requestIds
spring.cloud.openfeign.client.decode-slash=false
| Property | Default | Meaning |
|---|---|---|
url |
— | base URL when @FeignClient(url:) is not given (or empty); http:// is put in front of one without a scheme |
read-timeout |
60s |
the longest wait for data — for the response, then between two chunks of the body — not a limit for the whole call, like Feign's (10s, PT1M, milliseconds; 0 for none) |
connect-timeout |
10s |
connection timeout of the default transport (default only; 0 for none) |
logger-level |
none |
none, basic, headers, full (see Logging) |
dismiss404 |
false |
a 404 gives null (nullable results) or a ResponseEntity instead of an exception; a void method still throws, like Feign |
default-request-headers.<Name> |
— | a header sent with every request that has none of that name (after the interceptors; a,b or a YAML list gives two values) |
default-query-parameters.<name> |
— | a query parameter added to every request that has none of that name (after the interceptors; a,b or a YAML list gives two) |
retryer |
— | the Retryer of the client: a bean name, or a class name matching one bean (see Per-client configuration) |
error-decoder |
— | the ErrorDecoder of the client (bean or class name) |
request-interceptors |
— | more RequestInterceptors for the client (a list of bean or class names) |
follow-redirects |
true |
whether redirects are followed |
spring.cloud.openfeign.client.decode-slash |
true |
for all clients: a / in a @PathVariable value stays a / (false: %2F) |
The on/off properties (dismiss404, follow-redirects,
event-stream.reconnect, decode-slash) take true / false in any case
(also on / off, yes / no, 1 / 0), like Boot's binding; other
values fail naming the key.
@FeignClient(url:) wins over the url property; @FeignClient(path:)
prefixes every path. More attributes, as in Spring Cloud OpenFeign:
| Attribute | Meaning |
|---|---|
contextId: 'catalog-admin' |
the configuration key instead of the name (...config.catalog-admin.*), e.g. for two clients of one service |
qualifiers: ['books'] |
more names that select the client bean with @Qualifier('books') |
primary: false |
the client bean is not @Primary (it is by default) |
dismiss404: true |
like the dismiss404 property |
configuration: [CatalogFeignConfig] |
configuration classes whose beans apply to this client only (see below) |
Per-client configuration #
Like Spring Cloud OpenFeign, @FeignClient(configuration:) lists classes
whose Retryer, ErrorDecoder and RequestInterceptor beans configure
that client only:
@FeignClient('catalog', url: r'${catalog.url}',
configuration: [CatalogFeignConfig])
abstract interface class CatalogClient { ... }
@Configuration()
class CatalogFeignConfig {
@Bean()
Retryer catalogRetryer() => const DefaultRetryer(maxAttempts: 3);
@Bean()
RequestInterceptor catalogAuth() =>
BasicAuthRequestInterceptor('shop', 's3cret');
}
The client's own Retryer / ErrorDecoder win over the application's;
its interceptors run together with the application's, in @Order. Beans
of a configuration class never reach the other clients. The retryer,
error-decoder and request-interceptors properties win over both.
Errors #
A response with status ≥ 400 throws a FeignException subclass:
| Status | Exception |
|---|---|
| 400, 401, 403, 404, 405, 406, 409, 410, 415, 422, 429 | FeignBadRequest, FeignUnauthorized, FeignForbidden, FeignNotFound, FeignMethodNotAllowed, FeignNotAcceptable, FeignConflict, FeignGone, FeignUnsupportedMediaType, FeignUnprocessableEntity, FeignTooManyRequests (all FeignClientException) |
| 500, 501, 502, 503, 504 | FeignInternalServerError, FeignNotImplemented, FeignBadGateway, FeignServiceUnavailable, FeignGatewayTimeout (all FeignServerException) |
| no response (connection, timeout) | RetryableException with status -1 |
| a body that does not fit the return type | DecodeException |
try {
await catalog.find(isbn);
} on FeignNotFound {
return null;
} on FeignException catch (error) {
log.warning('catalog failed: ${error.status} ${error.responseBody}');
rethrow;
}
Your own exceptions: declare an ErrorDecoder bean.
final class ShopErrors implements ErrorDecoder {
@override
Exception decode(String methodKey, HttpResponse response) =>
response.statusCode == 404
? BookNotFound(response.request!.url.path)
: const DefaultErrorDecoder().decode(methodKey, response);
}
@Configuration()
class FeignSetup {
@Bean()
ErrorDecoder errorDecoder() => ShopErrors();
}
Retries #
Like Spring Cloud OpenFeign, nothing is retried by default. A Retryer
bean retries RetryableExceptions: connection errors, timeouts, and
responses with a Retry-After header (seconds or an HTTP date). Every
attempt is built again from the arguments and goes through the
interceptors, so a refreshed token reaches the retry.
@Bean()
Retryer retryer() => const DefaultRetryer(maxAttempts: 3); // 150 ms, 225 ms, ... up to 1 s, like Feign
Interceptors #
Every RequestInterceptor bean changes every request before it is sent:
authentication, tracing ids, common query parameters.
@Configuration()
class FeignSetup {
@Bean()
RequestInterceptor auth(TokenStore tokens) =>
BearerTokenRequestInterceptor(() => tokens.accessToken); // may be async
@Bean()
RequestInterceptor requestIds() => RequestIds();
}
final class RequestIds implements RequestInterceptor {
@override
void apply(RequestTemplate template) {
template.header('X-Request-Id', newId());
if (template.clientName == 'catalog') template.query('lang', 'en');
}
}
template.header(name, value) adds a value, like Feign's; to replace a
header, removeHeader(name) first. The interceptors run for every attempt
of a call (retries too) on a new template; the default-request-headers,
default-query-parameters and the default Accept come after them, for
names they did not set. BasicAuthRequestInterceptor(user, password) is
built in too (ISO-8859-1 like Feign's; encoding: utf8 for other
characters).
Fallback #
A bean class implementing the interface answers when a call fails:
@FeignClient('motd', url: r'${motd.url}', fallback: OfflineMotd)
abstract interface class MotdApi {
@GetMapping('/motd')
Future<String> messageOfTheDay();
}
@Component()
class OfflineMotd implements MotdApi {
@override
Future<String> messageOfTheDay() async => 'offline, try later';
}
The generated client is @Primary (unless @FeignClient(primary: false)),
so injecting MotdApi gives the client, not the fallback. The fallback answers every failure — a 404
too — after the retries.
Logging #
With logger-level above none, each call is logged under
feign.<Interface> at debug level (so the debug level must be on, e.g.
logging.level.feign=debug with spring_boot_starter_logging):
---> GET https://api.neon.books/books/978-0
<--- 200 OK (84 ms)
headers adds the headers (Authorization is masked), full the bodies.
Failed connections (<--- ERROR ...) and retries (---> RETRYING ...) are
logged only above none too, like Feign.
Transport #
Requests go through the HttpTransport bean: by default
platformHttpTransport() of spring_web_dart — dart:io on the VM and in
Flutter (mobile, desktop), fetch on the web (the server must allow your
origin with CORS). Declare your own HttpTransport bean to use something
else (e.g. a small adapter over another HTTP library).
Testing #
Replace the transport with MockHttpTransport from
spring_boot_starter_test (the dev dependency of the tests; like
Spring's MockRestServiceServer in spring-test):
final server = MockHttpTransport();
final app = springBootTest(
springApplication,
overrides: [BeanOverride<HttpTransport>(server)],
properties: {'catalog.url': 'https://catalog.test'},
);
test('reads a book', () async {
server.when(HttpMethod.get, '/books/1').respond(200, json: {'isbn': '1', 'title': 'Dune'});
expect(await app.getBean<Shop>().title('1'), 'Dune');
expect(server.lastRequest.headers['Accept'], 'application/json');
});
Or replace a whole client: BeanOverride<CatalogClient>(FakeCatalog()).
How it works #
@FeignClient is marked @InterfaceProxy (from spring_core_dart): for
such an interface the generator writes a class whose methods hand each call,
with the method's annotations, parameter names and types, to an
InvocationHandler. The starter's feignClientFactory bean creates that
handler. The client is a regular bean, so AOP applies to it — e.g.
@Cacheable on a client method (with spring_boot_starter_cache) caches
the responses.
Differences from Spring Cloud OpenFeign #
- No
@EnableFeignClients: the starter is enough. - Methods return
Futures (HTTP is asynchronous in Dart). - No service discovery or load balancing: every client needs a URL.
connect-timeoutis set for all clients (default);read-timeoutper client.- The fallback answers every failure, without a circuit breaker; there is no
FallbackFactoryyet. Accept: application/jsonis sent by default (text/event-streamfor event streams); Spring Cloud OpenFeign sendsAcceptonly forproduces.- Spaces in query parameters are sent as
+(form encoding), Feign sends%20. Authorizationis masked (***) in theheadersandfulllogs.- The logger is
feign.<Interface>(feign.CatalogClient); Feign'sSlf4jLoggerlogs under the interface's fully qualified class name. - Each retry starts from a new template (Feign runs the interceptors again on the same one, where header values pile up).
@FeignClient(configuration:)classes are ordinary@Configuration()beans (Dart cannot create a class by reflection, so they are not kept out of the application context as in Spring's child contexts); the starter instead keeps their beans away from the other clients, which is what Spring does when the class is not component-scanned. OnlyRetryer,ErrorDecoderandRequestInterceptorbeans are per client (notDecoder,Encoder,Contract,Client,Logger.Level).- The
retryer,error-decoderandrequest-interceptorsproperties name beans (by bean name, or by a class name matching exactly one bean); Spring creates the class when there is no bean of it. primaryandqualifiersare read by the generator; there is no<contextId>FeignClientalias. A method may have severalMapparameters (Feign: one query map and one header map).- Server-sent events (
EventStream<T>,event-stream.*properties) are an addition: OpenFeign has no streaming. - Form bodies take the object's properties as the
ObjectMapperwrites them (@JsonPropertynames), not feign-form's@FormProperty; a nested object in a form fails the call with anEncodeException. - Multipart bodies are built in memory (no streaming upload); a list of
plain values in a
@RequestPartis sent as one text field per item. - Not yet:
@SpringQueryMap, streaming of other response bodies (downloads).