Skip to main content

Requests

The Request object represents an incoming HTTP request to your Relic server. It provides access to all information about the client's request, including the HTTP method, URL, headers, query parameters, and body content.

Every handler in Relic receives a Request object as a parameter, allowing you to inspect and process the incoming data before generating a response.

Understanding the request object

When a client makes a request to your server, Relic creates a Request object that encapsulates all the details of that request. This object provides type-safe access to request data.

Note on mutability:

  • Request.headers is immutable.
  • Request.body is a Body wrapper and is mutable (handlers or middleware may replace it). The underlying body stream can be read only once.

The request flows through your middleware pipeline and reaches your handler, where you can extract the information you need to process the request and generate an appropriate response.

Key request properties

The Request object exposes several important properties:

  • method - The HTTP method (GET, POST, PUT, DELETE, etc.) as a Method enum value.
  • url - The complete original URI that was requested.
  • headers - Type-safe access to HTTP headers.
  • body - The request body wrapped in a Body helper. Use await request.readAsString() for text, or request.read() to access the byte stream. Both are single-read.
  • protocolVersion - The HTTP protocol version (typically 1.1).

Accessing request data

HTTP method

The request method indicates what action the client wants to perform. Relic uses a type-safe Method enum rather than strings, which prevents typos and provides better IDE support.

HTTP method
  app.get('/info', (final req) {
final method = req.method; // Method.get
return Response.ok(
body: Body.fromString('Received a ${method.name} request'),
);
});

Common methods include Method.get, Method.post, Method.put, Method.delete, Method.patch, and Method.options.

Request url and path

The url property provides the relative path and query parameters from the current handler's perspective. This is particularly useful when your handler is mounted at a specific path prefix.

Path parameters and URL
  app.get('/users/:id', (final req) {
final id = req.pathParameters.raw[#id]!;
final matchedPath = req.matchedPath;
final fullUri = req.url;

log('Matched path: $matchedPath, id: $id');
log('Full URI: $fullUri');

// Create a mock user object for the response and return it as JSON.
final user = {
'id': int.tryParse(id),
'name': 'User $id',
'email': 'user$id@example.com',
};

return Response.ok(
body: Body.fromString(jsonEncode(user), mimeType: MimeType.json),
);
});

Working with query parameters

Query parameters are key-value pairs appended to the URL after a question mark (?). They're commonly used to pass optional data, filters, or pagination information to your endpoints.

Single value parameters

You can access individual query parameter values through the queryParameters map. Each parameter is returned as a string, or null if not present.

Query parameters
  app.get('/search', (final req) {
final query = req.queryParameters.raw['query'];
final page = req.queryParameters.raw['page'];

if (query == null) {
return Response.badRequest(
body: Body.fromString('Query parameter "query" is required'),
);
}

final pageNum = int.tryParse(page ?? '1') ?? 1;
final results = {
'query': query,
'page': pageNum,
'results': ['Result 1', 'Result 2', 'Result 3'],
};

return Response.ok(
body: Body.fromString(jsonEncode(results), mimeType: MimeType.json),
);
});

When a client requests /search?query=relic&page=2, the query variable will contain "relic" and the page variable will contain "2". Both values are strings, so you'll need to parse them if you need other types like integers.

Typed query parameters

Manually parsing query parameters can be tedious and error-prone. Relic provides typed parameter accessors that handle parsing automatically and give you compile-time type safety.

Define a parameter accessor once, then use it to extract typed values:

Typed query parameters
  const pageParam = IntQueryParam('page');
const limitParam = IntQueryParam('limit');
const priceParam = DoubleQueryParam('price');

app.get('/products', (final req) {
// Automatically parsed as int (throws if missing or invalid)
final page = req.queryParameters.get(pageParam);
final limit = req.queryParameters.get(limitParam);

// Automatically parsed as double (throws if missing or invalid)
final maxPrice = req.queryParameters.get(priceParam);

final results = {
'page': page,
'limit': limit,
'max_price': maxPrice,
'products': ['Product A', 'Product B', 'Product C'],
};

return Response.ok(
body: Body.fromString(jsonEncode(results), mimeType: MimeType.json),
);
});

You can also use the nullable variant by calling the accessor directly:

Nullable typed query parameter
  app.get('/search-optional', (final req) {
final page = req.queryParameters(pageParam); // int? - null if missing
return Response.ok(body: Body.fromString('Page: $page'));
});

Relic provides these built-in typed query parameter accessors:

AccessorTypeDescription
IntQueryParamintInteger values (page numbers, limits)
DoubleQueryParamdoubleDecimal values (prices, percentages)
NumQueryParamnumAny numeric value
QueryParam<T>CustomCreate your own with a custom parser

For custom types, use QueryParam<T> with your own parsing function:

Custom type query parameters
// Custom enum parameter
const sortParam = QueryParam<SortOrder>('sort', SortOrder.parse);

// Custom date parameter
const fromParam = QueryParam<DateTime>('from', DateTime.parse);

To create a reusable accessor like the built-in ones, extend QueryParam<T> with a fixed decoder. The decoder must be a static function with signature T Function(String):

Custom QueryParam specialization
/// A reusable query parameter accessor for [DateTime] values.
final class DateTimeQueryParam extends QueryParam<DateTime> {
const DateTimeQueryParam(final String key) : super(key, DateTime.parse);
}

// Usage:
const fromDateParam = DateTimeQueryParam('from');
// In a handler: request.queryParameters.get(fromDateParam) returns DateTime
tip

Typed parameter accessors are defined as const values, which means they can be defined once at the top level and reused across multiple handlers. This promotes consistency and reduces boilerplate.

Multiple values

Some query parameters can appear multiple times in a URL to represent lists or arrays. The queryParametersAll map provides access to all values for each parameter name.

Multiple query values
  app.get('/filter', (final req) {
final tags = req.url.queryParametersAll['tag'] ?? [];
final results = {
'tags': tags,
'filtered_items':
tags.map((final tag) => 'Item tagged with $tag').toList(),
};

return Response.ok(
body: Body.fromString(jsonEncode(results), mimeType: MimeType.json),
);
});

For a request to /filter?tag=dart&tag=server&tag=web, the tags variable will be a list containing ["dart", "server", "web"]. This allows you to handle multiple selections or filters cleanly.

Reading headers

HTTP headers carry metadata about the request, such as content type, authentication credentials, and client information. Relic provides type-safe access to common headers, automatically parsing them into appropriate Dart types.

Type-safe header access

Instead of working with raw string values, Relic's type-safe headers give you properly typed objects. This eliminates parsing errors and provides better code completion in your IDE.

Type-safe headers
  app.get('/headers-info', (final req) {
final request = req;

// Get typed header values.
final mimeType = request.mimeType; // MimeType? (from Content-Type)
final userAgent = request.headers.userAgent; // String?
final contentLength = request.headers.contentLength; // int?

final info = {
'browser': userAgent ?? 'Unknown',
'content_type': mimeType?.toString() ?? 'None',
'content_length': contentLength ?? 0,
};

return Response.ok(
body: Body.fromString(jsonEncode(info), mimeType: MimeType.json),
);
});

In this example, the mimeType is automatically parsed into a MimeType object, and contentLength is parsed into an integer rather than a string. This type safety helps catch errors at compile time.

Authorization headers

The authorization header receives special handling in Relic to distinguish between different authentication schemes like Bearer tokens and Basic authentication.

Authorization header parsing
  app.get('/protected', (final req) {
final auth = req.headers.authorization;

if (auth is BearerAuthorizationHeader) {
final token = auth.token;
// In a real application, validate the token here.
return Response.ok(
body: Body.fromString(
jsonEncode({
'message': 'Access granted',
'token_length': token.length,
}),
mimeType: MimeType.json,
),
);
} else if (auth is BasicAuthorizationHeader) {
final username = auth.username;
// In a real application, validate the credentials here.
return Response.ok(
body: Body.fromString(
jsonEncode({'message': 'Basic auth received', 'username': username}),
mimeType: MimeType.json,
),
);
} else {
return Response.unauthorized();
}
});

Relic automatically parses the authorization header and creates the appropriate header object type, making it easy to handle different authentication schemes in a type-safe manner.

Reading the request body

The request body contains data sent by the client, typically in POST, PUT, or PATCH requests. Relic provides multiple ways to read body content depending on your needs.

Single read limitation

The request body can only be read once. This is because the body is a stream that gets consumed as it's read. Attempting to read the body multiple times will result in a StateError.

// ❌ WRONG - This will throw an error
final first = await request.readAsString();
final second = await request.readAsString(); // StateError!

// ✅ CORRECT - Read once and store the result
final body = await request.readAsString();
// Use 'body' as many times as needed

Reading as string

The most common way to read the body is as a string, which works well for JSON, XML, or plain text data. The readAsString method automatically handles character encoding based on the Content-Type header.

Read body as string
  app.post('/submit', (final req) async {
final bodyText = await req.readAsString();
return Response.ok(
body: Body.fromString(
jsonEncode({'received': bodyText, 'length': bodyText.length}),
mimeType: MimeType.json,
),
);
});

The method defaults to UTF-8 encoding if no encoding is specified in the request headers.

Parsing JSON data

For JSON APIs, you'll typically read the body as a string and then decode it using Dart's jsonDecode function. This two-step process gives you control over error handling.

Parse JSON body with validation
  app.post('/api/users', (final req) async {
try {
final bodyText = await req.readAsString();
final data = jsonDecode(bodyText) as Map<String, dynamic>;

final name = data['name'] as String?;
final email = data['email'] as String?;

if (name == null || email == null) {
return Response.badRequest(
body: Body.fromString(
jsonEncode({'error': 'Name and email are required'}),
mimeType: MimeType.json,
),
);
}

// Create a mock user object with generated data.
final newUser = {
'id': DateTime.now().millisecondsSinceEpoch,
'name': name,
'email': email,
'created_at': DateTime.now().toIso8601String(),
};

return Response.ok(
body: Body.fromString(
jsonEncode({'message': 'User created', 'user': newUser}),
mimeType: MimeType.json,
),
);
} catch (e) {
return Response.badRequest(
body: Body.fromString(
jsonEncode({'error': 'Invalid JSON: $e'}),
mimeType: MimeType.json,
),
);
}
});

This example shows proper validation of both the JSON structure and the required fields, providing clear error messages when something is wrong.

Reading as a byte stream

For large files or binary data, you can read the body as a stream of bytes to avoid loading everything into memory at once. This is essential for handling file uploads or large payloads efficiently.

Read body as byte stream
  app.post('/upload', (final req) async {
if (req.isEmpty) {
return Response.badRequest(
body: Body.fromString(
jsonEncode({'error': 'Request body is required'}),
mimeType: MimeType.json,
),
);
}

final stream = req.read(); // Stream<Uint8List>
int totalBytes = 0;

await for (final chunk in stream) {
totalBytes += chunk.length;
// Process chunk...
}

return Response.ok(
body: Body.fromString(
jsonEncode({
'message': 'Upload successful',
'bytes_received': totalBytes,
}),
mimeType: MimeType.json,
),
);
});

By processing the data in chunks, your server can handle large uploads without running out of memory.

Checking if body is empty

Before attempting to read the body, you can check if it's empty using the isEmpty property. This is useful when you want to require a body for certain requests.

warning

isEmpty is based on the known content length when available. For requests using chunked transfer encoding (unknown length up front), isEmpty may return false even if no data is ultimately sent. Prefer defensive reads for streaming uploads.

Body empty check
  app.post('/upload', (final req) async {
if (req.isEmpty) {
return Response.badRequest(
body: Body.fromString(
jsonEncode({'error': 'Request body is required'}),
mimeType: MimeType.json,
),
);
}

final stream = req.read(); // Stream<Uint8List>
int totalBytes = 0;

await for (final chunk in stream) {
totalBytes += chunk.length;
// Process chunk...
}

return Response.ok(
body: Body.fromString(
jsonEncode({
'message': 'Upload successful',
'bytes_received': totalBytes,
}),
mimeType: MimeType.json,
),
);
});

This check doesn't consume the body stream, so you can still read the body afterward.

Final tips

The Request object is your gateway to understanding what clients are asking for. By leveraging Relic's type-safe API, you can build secure, reliable handlers that properly validate and process client input.

Key principles for working with requests include accessing data through the Request parameter, using type-safe properties for methods and headers, reading query parameters safely, and handling request bodies appropriately. Remember that request bodies can only be read once, so design your handlers to consume the body early in the processing pipeline.

Always validate all incoming data since query parameters, headers, and body content come from untrusted sources. Use try-catch blocks for JSON parsing and validation to provide meaningful error responses. By following these patterns, you'll create handlers that are both secure and user-friendly.

Examples & further reading

Examples

  • Requests example - Comprehensive example covering complete request-response cycles.

API documentation

Further reading