spring_boot_starter_feign 0.6.1
spring_boot_starter_feign: ^0.6.1 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.
Declarative HTTP clients for
spring_boot_dart
applications. You describe a remote API as an abstract class, one method
per endpoint, and inject it like any other bean. Calling a method sends
the HTTP request and gives you the decoded answer.
Calling another service by hand means building URLs, encoding JSON,
sending the request, checking the status and decoding the answer, for
every endpoint. This starter does that part. The methods use the same
mapping annotations as a controller (@GetMapping, @PathVariable(),
@RequestBody(), ...), and the generator writes the implementation at
build time, without reflection. JSON goes through the ObjectMapper of
spring_core_dart. Error statuses become exceptions you can catch, and
timeouts, logging, retries, request interceptors and fallbacks are set
per client.
Use it in a server that calls other services, or in a Flutter app that calls its backend. It is the Dart counterpart of Spring Cloud OpenFeign.
Install #
dependencies:
spring_boot_starter_feign: ^0.6.1
dev_dependencies:
build_runner: ^2.17.0
spring_core_dart_generator: ^0.6.1 # required: writes the client implementations
dart run build_runner build
There is no @EnableFeignClients: listing the starter is enough. Every
abstract class with @FeignClient in the scanned package becomes a bean,
named after the interface (CatalogClient → 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, as every
Spring Boot starter does.
A first client #
The shop asks a catalog service for books:
// The catalog service's API, as the shop calls it.
@FeignClient('catalog', url: r'${catalog.url}')
abstract interface class CatalogClient {
@GetMapping('/books/{isbn}')
Future<Book> find(@PathVariable() String isbn);
// GET /books?q=dune&page=2; page is left out when null.
@GetMapping('/books')
Future<List<Book>> search(@RequestParam('q') String query, {@RequestParam() int? page});
// The book is sent as JSON; the answer gives the status and headers too.
@PostMapping('/books')
Future<ResponseEntity<Book>> create(@RequestBody() Book book);
@DeleteMapping('/books/{isbn}')
Future<void> delete(@PathVariable() String isbn);
}
@Introspected() // read and written as 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); // the generated client, 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
Classes that the client methods take or return are introspected for
JSON automatically when they are in the same package as the client: the
answers with their constructor and setters, the arguments with their
getters. @Introspected() on a class describes everything.
In the guides: server, 9. Feign, app, 6. Server access.
Methods #
Each method describes one endpoint. It needs exactly one mapping
annotation, which gives the HTTP method and the path. It must return a
Future, because HTTP calls are asynchronous, or an EventStream<T> for
server-sent events (see Server-sent events).
| 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 method's path is added to the client's url and path. A mapping
without a path (@GetMapping()) calls the client's URL and path
themselves: with @FeignClient(url: 'https://api.shop.test', path: '/api')
it calls https://api.shop.test/api. A query written in the path
(@GetMapping('/books?sort=title')) is sent as written, and the call's
own query parameters are appended to it.
A mapping annotation can also set:
headers: fixed headers sent with every call of the method, written as Spring writes them,Name=value, with${...}placeholders resolved from the configuration ('X-Shop=${shop.name}'). Spring's server-side conditions such as'!X-Debug'are ignored. Feign's own syntax,'Name: value', stops the application at startup.produces: theAcceptheader (application/jsonby default).consumes: theContent-Typeof the body.
With a path, these are written with the .of constructor, because a Dart
constructor cannot have both an optional positional parameter and named
ones: @GetMapping.of(path: '/books', headers: ['X-Shop=neon']).
Parameters #
Each parameter's annotation says where its argument goes in the request:
| Parameter | Goes to |
|---|---|
@PathVariable() String isbn |
{isbn} in the path, URL-encoded; a / in the value stays a / (see decode-slash). @PathVariable('id') for another name. A value . or .. (or with such a segment between slashes) throws an ArgumentError, as it would change the path |
@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. All cookies of a call go in one Cookie: a=1; b=2 header; null is left out |
@RequestBody() Book book |
the body: an object as JSON (through the ObjectMapper), a String as text, a Uint8List as bytes. With consumes: MediaType.applicationFormUrlencoded (or that Content-Type header), a Map or an object is sent as a form (see Form bodies) |
@RequestPart('file') MultipartFile file |
one part of a multipart/form-data body (see Files and multipart) |
| a parameter without annotation | the body (only one such parameter is allowed) |
Whether an argument may be left out follows from the Dart parameter
itself. Java's required and defaultValue attributes have no
counterpart here:
| Parameter | An argument of null |
|---|---|
nullable (String? sort) |
the query parameter, header or part is left out; a path variable becomes an empty string |
with a default value ({String sort = 'asc'}) |
not possible; without an argument, the default is sent |
| non-nullable | not possible |
Paths, query parameters, headers and cookies take single values (or
lists of them). Enums, DateTime and similar values are written as in
JSON: an enum by its name or its @JsonValue, a date in ISO-8601. A
parameter whose type is an object, meaning a class the ObjectMapper
writes as a JSON object (@RequestParam() Book book), stops the
application at startup: pass its fields as separate parameters or as a
Map instead. When only the runtime value can tell, as for a parameter of
type Object, an object fails the call with an EncodeException.
Results #
The return type says how the response is read:
| Return type | From the response |
|---|---|
Future<Book>, Future<List<Book>>, Future<Map<String, int>> |
the JSON body, read through the ObjectMapper |
Future<Book?> |
null for an empty body (or for a 404 with dismiss404) |
Future<void> |
nothing; the status must be 2xx |
Future<String>, Future<Uint8List> |
the body as text, or as bytes |
Future<ResponseEntity<Book>> |
the status, the headers (read-only) and the body |
Future<ResponseEntity<void>> |
the status and headers; the body is ignored |
Future<HttpResponse> |
the raw response, whatever its status: no error decoding, no dismiss404 (Feign's Response) |
EventStream<OrderEvent>, Stream<OrderEvent> |
the events of a text/event-stream response (see Server-sent events) |
A non-nullable result with an empty body, or a body that does not fit
the type, throws a DecodeException.
Form bodies #
Some APIs, such as login endpoints, expect an HTML-form body instead of
JSON. With consumes: MediaType.applicationFormUrlencoded, the body
parameter is sent as name=value pairs: the entries of a Map, or the
properties of an object as the ObjectMapper writes them. The pairs are
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.
// The login form of an older service.
@PostMapping.of(path: '/login', consumes: MediaType.applicationFormUrlencoded)
Future<void> login(@RequestBody() Map<String, String> form);
// sends: username=neo&password=s3cr%3Ft
This is what Spring Cloud OpenFeign's SpringFormEncoder does.
Checked at startup #
Every client is checked when it is created, so a mistake in an interface shows up when the application starts, not at the first call. The application stops with the reasons when it finds:
- a
{variable}in the path without a@PathVariableparameter, or the other way round; - a method without a mapping annotation, or with several;
- a method that returns neither a
Futurenor an event stream; - an object as a path, query, header or cookie value;
- a header written in Feign's
Name: valuesyntax; - a client without a URL.
Invalid Feign client CatalogClient:
- CatalogClient.find: the path variable {isbn} of "/books/{isbn}" has no @PathVariable parameter
Files and multipart #
To upload files, give a method @RequestPart parameters: it then sends a
multipart/form-data body, one part per parameter. consumes: MediaType.multipartFormData is optional; any other consumes stops the
application at startup.
// Uploads a book's cover with its description.
@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=false
);
await books.upload(
'978-0',
InMemoryMultipartFile('file', bytes, originalFilename: 'cover.png'),
FileMeta(title: 'Cover'),
'first edition',
false,
);
@RequestPart('x') parameter |
Part |
|---|---|
MultipartFile |
a file. Its filename and content type come from the file (else the content type is guessed from the file name, else application/octet-stream). The part is named after the annotation, not after file.name |
List<MultipartFile> |
one file part per file, all named x |
Uint8List, List<int> |
a file part without a file name (application/octet-stream) |
String, numbers, bool, enums, DateTime (or a List of them) |
a text field (one per item), written as query parameters are |
an object, a Map, a List of objects |
a JSON part (application/json), written with the client's ObjectMapper |
MultipartFile is the same type controllers receive (from
spring_web_dart), so a file a controller received can be passed on as
it is. InMemoryMultipartFile builds one from bytes, or from text with
InMemoryMultipartFile.fromString. A nullable parameter
(@RequestPart('note') String? note) leaves its part out for null.
@RequestParam, @RequestHeader and @PathVariable work as on any other
method; @RequestParam stays in the query, as in Spring Cloud OpenFeign.
The Content-Type is multipart/form-data; boundary=... and replaces
consumes. A method with both @RequestPart and @RequestBody, or a
parameter with @RequestPart and another annotation, stops the
application at startup.
The body is built in memory once per call, reading each file once, so retries send the same bytes. There is no streaming upload yet.
Server-sent events #
Some services push updates over a long-lived response in the
text/event-stream format: order statuses, prices, progress. A method
that returns EventStream<T> reads such a response. EventStream<T> is a
Stream<T> of the decoded data, so the usual case needs no wrapper type;
the whole events, with their id, event and retry fields, are
available through .events.
@FeignClient('orders', url: r'${orders.url}')
abstract interface class OrdersClient {
@GetMapping('/shops/{shop}/orders/events')
EventStream<OrderEvent> events(@PathVariable() String shop);
}
// Only the data:
await for (final order in orders.events('neon')) {
log.info(order.status);
}
// The whole events, when the id or event name matters:
orders.events('neon').events.listen((e) {
log.info('${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 arrives as an error on the stream (aDecodeException), and the stream goes on. - The stream is cold: each listener opens its own connection. Cancelling
the subscription (or
breakinawait for) closes it. - The request carries
Accept: text/event-stream(produceschanges it) andCache-Control: no-cache. Interceptors run for every connection, so a refreshed token is used when the stream reconnects. read-timeoutcovers only the wait for the response headers; after that, the 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 someone listens.
The stream reconnects as a browser's EventSource does. When the
response ends or the connection breaks, the client connects again after a
delay (a retry: field from the server replaces the configured one) and
sends 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, unless that exception is a RetryableException, which
reconnects.
spring.feign.client.config.orders.event-stream.reconnect=true
spring.feign.client.config.orders.event-stream.reconnect-delay=3s
spring.feign.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 |
how many connections in a row may end 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); // the reconnect: 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 and reconnects by itself; the event-stream.*
properties are additions of this starter.
Configuration #
Each client is configured under its name (@FeignClient('catalog')).
The name default applies to every client; a client's own setting wins.
The keys are those of Spring Cloud OpenFeign:
spring.feign.client.config.default.read-timeout=10s
spring.feign.client.config.default.connect-timeout=2s
spring.feign.client.config.default.call-timeout=15s
spring.feign.client.config.catalog.url=https://api.neon.books
spring.feign.client.config.catalog.logger-level=basic
spring.feign.client.config.catalog.dismiss404=true
spring.feign.client.config.catalog.default-request-headers.X-Shop=neon
spring.feign.client.config.catalog.default-query-parameters.lang=en
spring.feign.client.config.catalog.follow-redirects=false
spring.feign.client.config.catalog.retryer=catalogRetryer
spring.feign.client.config.catalog.error-decoder=com.example.ShopErrors
spring.feign.client.config.catalog.request-interceptors[0]=requestIds
spring.feign.client.decode-slash=false
| Property | Default | Meaning |
|---|---|---|
url |
— | the base URL when @FeignClient(url:) is not given or is empty. http:// is put in front of a URL without a scheme |
read-timeout |
60s |
the longest wait for data: for the response, then between two chunks of the body. It is not a limit for the whole call, as in Feign. 10s, PT1M or milliseconds; 0 for none |
connect-timeout |
10s |
the connection timeout of the default transport. Read under default only, for all clients; 0 for none |
call-timeout |
none | the longest one call may take in all, retries and pauses included; 0 for none (see A limit for the whole call) |
logger-level |
none |
none, basic, headers or full (see Logging) |
dismiss404 |
false |
a 404 gives null (nullable results) or a ResponseEntity instead of an exception; a void method still throws, as in Feign |
default-request-headers.<Name> |
— | a header added to every request that has none of that name once the interceptors ran. 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 once the interceptors ran. a,b or a YAML list gives two values. The URL is in the logs and in exception messages, so send a secret API key as a header in sensitive-headers instead |
retryer |
— | the client's Retryer: a bean name, or a class name matching exactly one bean (see Per-client configuration) |
error-decoder |
— | the client's ErrorDecoder, by bean name or class name |
request-interceptors |
— | more RequestInterceptors for the client: a list of bean names or class names, those under default first |
follow-redirects |
true |
whether redirects are followed; with false, a 3xx answer is an error status |
sensitive-headers |
— | more headers whose values the headers and full logs show as ***, such as X-Api-Key: a list, those under default first (see Logging) |
spring.feign.client.decode-slash |
true |
one setting for all clients: a / in a @PathVariable value stays a / (false: it is sent as %2F) |
The default-request-headers and default-query-parameters of a client
add to those of default, and win for the same name.
The on/off properties (dismiss404, follow-redirects,
event-stream.reconnect, decode-slash) take true / false in any
case, and also on / off, yes / no, 1 / 0, as Spring Boot's
binding does. Another value stops the application with an error naming
the key.
@FeignClient(url:) wins over the url property, and
@FeignClient(path:) is put before every method's path. The annotation
has more attributes, as in Spring Cloud OpenFeign:
| Attribute | Meaning |
|---|---|
contextId: 'catalog-admin' |
the configuration key to use instead of the name (...config.catalog-admin.*), for example for two clients of one service with different settings |
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 |
the same as the dismiss404 property |
configuration: [CatalogFeignConfig] |
configuration classes whose beans apply to this client only (see below) |
Per-client configuration #
Sometimes one service needs other credentials or another retry policy
than the rest. @FeignClient(configuration:) lists classes whose
Retryer, ErrorDecoder and RequestInterceptor beans configure that
client only:
// The catalog needs its own account and a few retries.
@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');
}
A listed class must be a @Configuration() bean; the application stops
otherwise. Beans of a configuration class never reach the other clients.
For each client the starter picks:
- Retryer and error decoder: the
retryer/error-decoderproperty when set; else the bean of the client's configuration classes; else the application's bean; else no retries and theDefaultErrorDecoder. Two candidate beans at one level need one of them to be@Primary. - Interceptors: the application's
RequestInterceptorbeans and those of the client's configuration classes, all together in@Order; then the beans that therequest-interceptorsproperty names. The property adds to the beans, it does not replace them. A bean that is both an interceptor bean and named in the property runs once.
Errors #
A response with a status of 400 or more throws a FeignException
subclass, so callers catch exactly the cases they handle:
| 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) |
| another 4xx | FeignClientException |
| 500, 501, 502, 503, 504 | FeignInternalServerError, FeignNotImplemented, FeignBadGateway, FeignServiceUnavailable, FeignGatewayTimeout (all FeignServerException) |
| another 5xx | FeignServerException |
a response with a Retry-After header |
RetryableException with the response's status |
| no response (connection failed, timeout) | RetryableException with status -1 |
| a body that does not fit the return type | DecodeException |
| arguments that cannot be written into the request | EncodeException, before anything is sent |
// A missing book is not an error for the shop; anything else is.
try {
await catalog.find(isbn);
} on FeignNotFound {
return null;
} on FeignException catch (error) {
log.warning('catalog failed: ${error.status} ${error.responseBody}');
rethrow;
}
To throw exceptions of your own domain instead, or to read the error
document a service sends, declare an ErrorDecoder bean. It is called for
every status outside 2xx, except a dismissed 404 and methods returning
HttpResponse:
// The catalog's 404 becomes the shop's own exception;
// every other status keeps the default handling.
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();
}
Returning a RetryableException from a decoder makes that answer
retryable, for example every 503.
Retries #
A remote service may be briefly out of reach, time out, or ask to be
called later. A Retryer bean tries such calls again, so the code around
each call does not have to. As in Spring Cloud OpenFeign, nothing is
retried by default.
Only RetryableExceptions are retried: connection errors, timeouts, and
responses with a Retry-After header (in seconds or as an HTTP date). An
error status alone is not retried. Every attempt is built again from the
arguments and goes through the interceptors, so a token refreshed in the
meantime reaches the retry.
// Up to 3 attempts per call: pauses of 150 ms, then 225 ms.
@Bean()
Retryer retryer() => const DefaultRetryer(maxAttempts: 3);
DefaultRetryer works as Feign's Retryer.Default: by default 5
attempts in all, pauses of period × 1.5ⁿ (150 ms, 225 ms, 338 ms, ...
with the default period of 100 ms), never longer than maxPeriod
(1 second). When the server sent Retry-After, it waits until then
instead, still at most maxPeriod.
A limit for the whole call: call-timeout #
read-timeout limits each wait for data, and a Retryer multiplies the
attempts, so one call can take much longer than any timeout says. Set
call-timeout for the time the call may take in all: from the first
attempt, through every pause and retry, to the last byte of the answer.
spring.feign.client.config.catalog.call-timeout=5s
When it passes, the call throws a FeignCallTimeoutException (a
FeignException with status -1, the number of attempts, and whether
the limit was the caller's deadline). It is never retried, whatever
the Retryer says, because the time is gone: a pause the retryer asks for
that would end past the limit ends the call at once instead of sleeping to
it. The fallback of the client still answers it, as any failure. The
connect and read timeouts of each attempt are capped by what remains, so a
read-timeout of 60 seconds cannot outlast a call-timeout of 5.
A call made while a web request is handled (server.request-timeout of the
web starter), or inside RequestDeadline.run(...), has a second limit: the
deadline of the code that makes it. The call is limited by the earlier
of the two, so it never outlives its request. The interceptors run under
the same limit.
// At most 2 seconds for the whole checkout, whatever the clients do.
final order = await RequestDeadline.run(
const Duration(seconds: 2),
() async {
final stock = await warehouse.reserve(basket);
return payments.charge(basket, stock);
},
);
For an EventStream<T> method the limit applies to establishing the
connection (until the status and headers arrive), each time it connects,
not to the life of the stream. A stream that cannot connect within it fails
with the FeignCallTimeoutException and does not reconnect.
call-timeout has no default and no counterpart in Spring Cloud OpenFeign,
which limits only the connect and the read of each attempt.
Interceptors #
A RequestInterceptor changes every request before it is sent. Use it
for what all calls need and no method should repeat: an authentication
header, a tracing id, a common query parameter. Every RequestInterceptor
bean applies to every client, in @Order.
@Configuration()
class FeignSetup {
// The user's token; the function may be async, and it is asked again
// at every attempt.
@Bean()
RequestInterceptor auth(TokenStore tokens) =>
BearerTokenRequestInterceptor(() => tokens.accessToken);
@Bean()
RequestInterceptor requestIds() => RequestIds();
}
// Every call carries a request id; the catalog also gets the language.
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, as Feign's does. To replace a header, callremoveHeader(name)first.applymay return aFuture, for example to fetch a fresh token; the call waits for it. An exception fails the call.- The interceptors run for every attempt of a call, retries included, each
time on a new template. The
default-request-headers,default-query-parametersand the defaultAcceptheader are added after them, for names they did not set. BearerTokenRequestInterceptor(token)sendsAuthorization: Bearer ...and adds nothing whentokenreturnsnull.BasicAuthRequestInterceptor(user, password)sends HTTP Basic credentials, encoded in ISO-8859-1 as Feign does; passencoding: utf8for other characters.
Fallback #
A fallback answers instead of the remote service when a call fails, for example with cached or placeholder data. It is a bean class that implements the same interface:
@FeignClient('motd', url: r'${motd.url}', fallback: OfflineMotd)
abstract interface class MotdApi {
@GetMapping('/motd')
Future<String> messageOfTheDay();
}
// What the app shows while the service is unreachable.
@Component()
class OfflineMotd implements MotdApi {
@override
Future<String> messageOfTheDay() async => 'offline, try later';
}
The fallback answers every failure, a 404 too, after the retries. The
generated client is @Primary (unless @FeignClient(primary: false)), so
injecting MotdApi gives the client, not the fallback.
Logging #
To see what a client sends and receives, set its logger-level above
none. Each call is then logged under the logger feign.<Interface> at
debug level, so that level must be on too, for example
logging.level.feign=debug with spring_boot_starter_logging:
---> GET https://api.neon.books/books/978-0
<--- 200 OK (84 ms)
logger-level |
Logged |
|---|---|
none |
nothing (the default) |
basic |
the method and URL of each request, the status and time of each response, failed connections (<--- ERROR ...), retries (---> RETRYING ...) and event-stream reconnects (---> RECONNECTING ...) |
headers |
basic plus the request and response headers; the values of Authorization, Proxy-Authorization, Cookie, Set-Cookie, DPoP and the sensitive-headers are shown as *** |
full |
headers plus the bodies |
As in Feign, failed connections and retries are not logged at none.
The URL is logged with its query, so a key sent as a query parameter shows; send secrets as headers and name the ones the starter does not mask already:
spring.feign.client.config.default.sensitive-headers=X-Api-Key,X-Partner-Token
Transport #
Requests go through the HttpTransport bean. By default it is
platformHttpTransport() of spring_web_dart: dart:io on the VM and in
Flutter (mobile, desktop), fetch on the web, where the server must allow
your origin with CORS. Declare your own HttpTransport bean to send
through something else, such as a small adapter over another HTTP
library, or a mock in tests.
Testing #
In tests, replace the transport with MockHttpTransport, which answers
with prepared responses and records what was sent. It comes with
spring_boot_starter_test, the tests' dev dependency, and plays the part
of Spring's MockRestServiceServer:
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');
});
To test code that uses a client without HTTP at all, replace the 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: for example,
@Cacheable on a client method (with spring_boot_starter_cache) caches
the responses.
Differences from Spring Cloud OpenFeign #
- No
@EnableFeignClients: the starter is enough. - The properties are
spring.feign.*, notspring.cloud.openfeign.*(Spring Cloud means nothing in Dart); the old name stops the start and names the new one. - Methods return
Futures, because HTTP is asynchronous in Dart. - No service discovery or load balancing: every client needs a URL.
connect-timeoutis one setting for all clients (default);read-timeoutis per client.call-timeoutis an addition: Spring Cloud OpenFeign limits the connect and the read of each attempt, never a call as a whole, and no deadline of the caller reaches the call. Here the call is limited by the earlier ofcall-timeoutand theRequestDeadlineit runs under.- 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. - The values of
Authorization,Proxy-Authorization,Cookie,Set-Cookie,DPoP(a proof of possession) and the headers of thesensitive-headersproperty (an addition of this starter) are masked (***) in theheadersandfulllogs; Feign's logger masks none. - A
@PathVariablevalue.or..throws anArgumentError: Dart'sUriremoves such segments (%2E%2Etoo), so the call would go to another path. Feign sends them as they are. - 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 cannot be kept out of the application context as Spring's child contexts do; 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).