DEV Community

Silfalion
Silfalion

Posted on • Edited on

Tip: add default headers to Serverpod endpoint calls today with a custom client

Serverpod does not currently expose a public defaultHeaders or headersProvider option for generated endpoint
HTTP calls.

That can be awkward for values like Accept-Language, tenant IDs, app-version metadata, or other request-scoped
headers you want on many endpoint calls without threading an extra argument through every endpoint method.

You can work around this today by subclassing the generated client and overriding callServerEndpoint.

Client workaround

import 'package:http/http.dart' as http;
import 'package:serverpod_client/serverpod_client.dart';

import 'src/protocol/client.dart';

class AppClient extends Client {
  AppClient(super.host, {super.securityContext});

  final _http = http.Client();

  final Map<String, String> defaultHeaders = {};

  @override
  Future<T> callServerEndpoint<T>(
    String endpoint,
    String method,
    Map<String, dynamic> args, {
    bool authenticated = true,
  }) async {
    final auth = authenticated ? await authKeyProvider?.authHeaderValue : null;

    final response = await _http
        .post(
          Uri.parse('$host$endpoint'),
          headers: {
            'content-type': 'application/json; charset=utf-8',
            if (auth != null) 'authorization': auth,
            ...defaultHeaders,
          },
          body: SerializationManager.encode({
            ...args,
            'method': method,
          }),
        )
        .timeout(connectionTimeout);

    if (response.statusCode != 200) {
      throw ServerpodClientException(response.body, response.statusCode);
    }

    if (T == getType<void>()) return null as T;
    return serializationManager.decode<T>(response.body, T);
  }

  @override
  void close() {
    _http.close();
    super.close();
  }
}

Enter fullscreen mode Exit fullscreen mode

Global usage

final client = AppClient('http://localhost:8080/')
  ..defaultHeaders['accept-language'] = 'fr-FR';
Enter fullscreen mode Exit fullscreen mode

Now normal generated endpoint calls made through that client include the header:

await client.greeting.hello('Ada');
Enter fullscreen mode Exit fullscreen mode

Grouped usage

If only one feature area needs the extra headers, prefer a preconfigured client for that feature instead of
mutating one shared client back and forth.

class LocalizedGreetingApi {
  LocalizedGreetingApi(Client baseClient)
      : client = AppClient(baseClient.host)
          ..authKeyProvider = baseClient.authKeyProvider
          ..defaultHeaders['accept-language'] = 'fr-FR';

  final AppClient client;

  Future<String> hello(String name) {
    return client.greeting.hello(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Server-side read

Future<String> hello(Session session, String name) async {
  final language = session.request?.headers['accept-language']?.firstOrNull;
  return 'hello $name ($language)';
}
Enter fullscreen mode Exit fullscreen mode

Important production note

This workaround extends the generated Serverpod client, but it does not hook into a smaller official “add headers
here” API. Serverpod’s lower-level endpoint implementation is private, and the internal request delegate currently
does not accept custom headers.

That means overriding callServerEndpoint takes ownership of the normal endpoint HTTP request path.

For simple cases, the snippet above is enough. For production apps that use RefresherClientAuthKeyProvider, JWT
refresh, onFailedCall, or onSucceededCall, you may want to preserve a little more of Serverpod’s built-in
behavior.

The main behavior to preserve is:

  • call onSucceededCall after successful endpoint calls
  • call onFailedCall when calls fail
  • map common HTTP status codes to Serverpod exceptions
  • on 401, refresh the auth key once and retry

Here is a more complete version:


import 'package:http/http.dart' as http;
import 'package:serverpod_client/serverpod_client.dart';

import 'src/protocol/client.dart';

class AppClient extends Client {
  AppClient(
    super.host, {
    super.securityContext,
    super.authenticationKeyManager,
    super.streamingConnectionTimeout,
    super.connectionTimeout,
    super.onFailedCall,
    super.onSucceededCall,
    super.disconnectStreamsOnLostInternetConnection,
  });

  final _http = http.Client();

  final Map<String, String> defaultHeaders = {};

  @override
  Future<T> callServerEndpoint<T>(
    String endpoint,
    String method,
    Map<String, dynamic> args, {
    bool authenticated = true,
  }) async {
    try {
      return await _callServerEndpointWithDefaultHeaders<T>(
        endpoint,
        method,
        args,
        authenticated: authenticated,
      );
    } on ServerpodClientUnauthorized {
      final keyProvider = authKeyProvider;

      if (keyProvider is RefresherClientAuthKeyProvider) {
        final refreshResult = await keyProvider.refreshAuthKey(force: true);

        if (refreshResult == RefreshAuthKeyResult.success) {
          return _callServerEndpointWithDefaultHeaders<T>(
            endpoint,
            method,
            args,
            authenticated: authenticated,
          );
        }
      }

      rethrow;
    }
  }

  Future<T> _callServerEndpointWithDefaultHeaders<T>(
    String endpoint,
    String method,
    Map<String, dynamic> args, {
    required bool authenticated,
  }) async {
    final callContext = MethodCallContext(
      endpointName: endpoint,
      methodName: method,
      arguments: args,
    );

    try {
      final auth = authenticated ? await authKeyProvider?.authHeaderValue : null;

      final response = await _http
          .post(
            Uri.parse('$host$endpoint'),
            headers: {
              'content-type': 'application/json; charset=utf-8',
              if (auth != null) 'authorization': auth,
              ...defaultHeaders,
            },
            body: SerializationManager.encode({
              ...args,
              'method': method,
            }),
          )
          .timeout(connectionTimeout);

      if (response.statusCode != 200) {
        throw _exceptionFromResponse(response);
      }

      final result = T == getType<void>()
          ? null as T
          : serializationManager.decode<T>(response.body, T);

      onSucceededCall?.call(callContext);
      return result;
    } catch (error, stackTrace) {
      onFailedCall?.call(callContext, error, stackTrace);
      rethrow;
    }
  }

  ServerpodClientException _exceptionFromResponse(http.Response response) {
    switch (response.statusCode) {
      case 400:
        return ServerpodClientBadRequest(response.body);
      case 401:
        return ServerpodClientUnauthorized();
      case 403:
        return ServerpodClientForbidden();
      case 404:
        return ServerpodClientNotFound();
      case 500:
        return ServerpodClientInternalServerError();
      default:
        return ServerpodClientException(response.body, response.statusCode);
    }
  }

  @override
  void close() {
    _http.close();
    super.close();
  }
}

Enter fullscreen mode Exit fullscreen mode

Caveats

  • This is for normal generated endpoint HTTP calls only.
  • This is not for method streams.
  • Do not use this to override authorization.
  • Do not use this to override content-type.
  • If you need arbitrary custom headers from browser clients, remember CORS can matter.
  • If your app relies on JWT refresh, use the production-aware version above or add equivalent 401 retry behavior.
  • This is a workaround until Serverpod exposes first-class default headers or a headers provider on the client.

Why not just extend a lower-level client hook?

A smaller hook would be the cleanest version of this.

At the moment, the public extension point for generated endpoint calls is callServerEndpoint. The lower-level
request implementation is kept private, which makes sense: Serverpod owns serialization, request formatting,
exception mapping, auth retry, and IO/browser differences there. Keeping that internal gives the framework room to
evolve without locking users into those details.

What would make this use case nicer is a focused public hook that only customizes headers, without exposing the
rest of the request pipeline. For example:

final client = Client(
  'http://localhost:8080/',
  headersProvider: () async => {
    'accept-language': currentLocale,
  },
);
Enter fullscreen mode Exit fullscreen mode

or:

client.defaultHeaders['accept-language'] = 'fr-FR';
Enter fullscreen mode Exit fullscreen mode

Until Serverpod adds an API along those lines, overriding callServerEndpoint is a pragmatic workaround. It keeps
the generated endpoint API intact, while making the tradeoff explicit: you are taking over the HTTP request path
for normal endpoint calls.

Top comments (0)