From 2337462b22f81f855a3fc49b73b0937cf813224e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 10:18:10 +0100 Subject: [PATCH 01/39] docs: Fix WebServer documentation to match actual implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation incorrectly referenced ComponentRoute and Component classes that don't exist. The Widget to Component rename was rolled back in the implementation but the docs were never updated. Changes: - Reverted ComponentRoute → WidgetRoute - Reverted Component → TemplateWidget/WebWidget - Reverted ListComponent, JsonComponent, RedirectComponent → ListWidget, JsonWidget, RedirectWidget - Updated RouteStaticDirectory → StaticRoute.directory() - Fixed method signature: HttpRequest request → Request request - Rewrote upgrade guide to reflect actual changes (Widget → TemplateWidget deprecation) - Added StaticRoute cache control examples using the newly static methods --- docs/06-concepts/18-webserver.md | 34 ++-- docs/08-upgrading/06-upgrade-to-three.md | 231 ++++++----------------- 2 files changed, 71 insertions(+), 194 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index ed802d04..22183e93 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -8,9 +8,9 @@ Serverpod's web server is still experimental, and the APIs may change in the fut ::: -When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `ComponentRoute` and `Component`. The `ComponentRoute` provides an entry point for a call to the server and returns a `Component`. The `Component` renders a web page or response using templates, JSON, or other custom means. +When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders a web page using templates, while other `WebWidget` types can render JSON or other custom responses. -## Creating new routes and components +## Creating new routes and widgets To add new pages to your web server, you add new routes. Typically, you do this in your server.dart file before you start the Serverpod. By default, Serverpod comes with a `RootRoute` and a static directory. @@ -24,22 +24,22 @@ pod.webServer.addRoute(MyRoute(), '/my/page/address'); pod.webServer.addRoute(AnotherRoute(), '/item/*'); ``` -Typically, you want to create custom routes for your pages. Do this by overriding the ComponentRoute class and implementing the build method. +Typically, you want to create custom routes for your pages. Do this by overriding the WidgetRoute class and implementing the build method. ```dart -class MyRoute extends ComponentRoute { +class MyRoute extends WidgetRoute { @override - Future build(Session session, HttpRequest request) async { - return MyPageComponent(title: 'Home page'); + Future build(Session session, Request request) async { + return MyPageWidget(title: 'Home page'); } } ``` -Your route's build method returns a Component. The Component consists of an HTML template file and a corresponding Dart class. Create a new custom Component by overriding the Component class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your `Component` class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest components, similarly to how widgets work in Flutter. +Your route's build method returns a `WebWidget`. The `TemplateWidget` consists of an HTML template file and a corresponding Dart class. Create a new custom widget by extending the `TemplateWidget` class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your widget class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest widgets, similarly to how widgets work in Flutter. ```dart -class MyPageComponent extends Component { - MyPageComponent({String title}) : super(name: 'my_page') { +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { values = { 'title': title, }; @@ -49,19 +49,19 @@ class MyPageComponent extends Component { :::info -In the future, we plan to add a component library to Serverpod with components corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side components with similar code used within Flutter. +In the future, we plan to add a widget library to Serverpod with widgets corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side widgets with similar code used within Flutter. ::: -## Special components and routes +## Special widgets and routes -Serverpod comes with a few useful special components and routes you can use out of the box. When returning these special component types, Serverpod's web server will automatically set the correct HTTP status codes and content types. +Serverpod comes with a few useful special widgets and routes you can use out of the box. When returning these special widget types, Serverpod's web server will automatically set the correct HTTP status codes and content types. -- `ListComponent` concatenates a list of other components into a single component. -- `JsonComponent` renders a JSON document from a serializable structure of maps, lists, and basic values. -- `RedirectComponent` creates a redirect to another URL. +- `ListWidget` concatenates a list of other widgets into a single widget. +- `JsonWidget` renders a JSON document from a serializable structure of maps, lists, and basic values. +- `RedirectWidget` creates a redirect to another URL. -To serve a static directory, use the `RouteStaticDirectory` class. Serverpod will set the correct content types for most file types automatically. +To serve a static directory, use the `StaticRoute.directory()` method. Serverpod will set the correct content types for most file types automatically. :::caution @@ -71,4 +71,4 @@ Static files are configured to be cached hard by the web browser and through Clo ## Database access and logging -The web server passes a `Session` object to the `ComponentRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. +The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. diff --git a/docs/08-upgrading/06-upgrade-to-three.md b/docs/08-upgrading/06-upgrade-to-three.md index 757b91e0..3180cba3 100644 --- a/docs/08-upgrading/06-upgrade-to-three.md +++ b/docs/08-upgrading/06-upgrade-to-three.md @@ -1,216 +1,93 @@ # Upgrade to 3.0 -## Web Server: Widget to Component Rename +## Web Server Changes -In Serverpod 3.0, all web server related "Widget" classes have been renamed to "Component" to better reflect their purpose and avoid confusion with Flutter widgets. +### Widget Class Naming Updates -The following classes have been renamed: +In Serverpod 3.0, the web server widget classes have been reorganized for better clarity: -| Old Name | New Name | -| ---------------- | ------------------- | -| `Widget` | `Component` | -| `AbstractWidget` | `AbstractComponent` | -| `WidgetRoute` | `ComponentRoute` | -| `WidgetJson` | `JsonComponent` | -| `WidgetRedirect` | `RedirectComponent` | -| `WidgetList` | `ListComponent` | +- The old `Widget` class (for template-based widgets) has been renamed to `TemplateWidget` +- The old `AbstractWidget` class has been renamed to `WebWidget` +- Legacy class names (`Widget`, `AbstractWidget`, `WidgetList`, `WidgetJson`, `WidgetRedirect`) are deprecated but still available for backward compatibility -### 1. Update Route Classes +The `WidgetRoute` class remains unchanged and continues to be the base class for web routes. -Update all route classes that extend `WidgetRoute` to extend `ComponentRoute`, and rename them to follow the new naming convention: +**Recommended migration:** -**Before:** - -```dart -class RouteRoot extends WidgetRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageWidget(); - } -} -``` - -**After:** - -```dart -class RootRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageComponent(); - } -} -``` - -### 2. Update Component Classes - -Update all classes that extend `Widget` to extend `Component`, and rename them from "Widget" to "Component": - -**Before:** +If you're using the old `Widget` class, update to `TemplateWidget`: ```dart +// Old (deprecated but still works) class MyPageWidget extends Widget { MyPageWidget({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; + values = {'title': title}; } } -``` -**After:** - -```dart -class MyPageComponent extends Component { - MyPageComponent({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` - -### 3. Update Abstract Components - -If you have custom abstract components, update them from `AbstractWidget` to `AbstractComponent` and rename accordingly: - -**Before:** - -```dart -class CustomWidget extends AbstractWidget { - @override - String toString() { - return '...'; - } -} -``` - -**After:** - -```dart -class CustomComponent extends AbstractComponent { - @override - String toString() { - return '...'; +// New (recommended) +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = {'title': title}; } } ``` -### 4. Update Special Component Types +### Static Route Updates -Update references to special component types: +The `RouteStaticDirectory` class has been deprecated in favor of `StaticRoute.directory()`: **Before:** ```dart -// JSON responses -return WidgetJson(object: {'status': 'success'}); - -// Redirects -return WidgetRedirect(url: '/login'); - -// Component lists -return WidgetList(widgets: [widget1, widget2]); +pod.webServer.addRoute( + RouteStaticDirectory( + serverDirectory: 'static', + basePath: '/', + ), + '/static/**', +); ``` **After:** ```dart -// JSON responses -return JsonComponent(object: {'status': 'success'}); - -// Redirects -return RedirectComponent(url: '/login'); - -// Component lists -return ListComponent(widgets: [widget1, widget2]); +pod.webServer.addRoute( + StaticRoute.directory(Directory('static')), + '/static/**', +); ``` -### 5. Update Route Registration - -Update your route registration to use the renamed route classes: - -**Before:** +The new `StaticRoute` provides better cache control options. You can use the built-in static helper methods for common caching scenarios: ```dart -pod.webServer.addRoute(RouteRoot(), '/'); -pod.webServer.addRoute(RouteRoot(), '/index.html'); +// Example with immutable public caching +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 3600), + ), + '/static/**', +); ``` -**After:** +Other available cache control factory methods: +- `StaticRoute.public(maxAge: seconds)` - Public cache with optional max-age +- `StaticRoute.publicImmutable(maxAge: seconds)` - Public immutable cache with optional max-age +- `StaticRoute.privateNoCache()` - Private cache with no-cache directive +- `StaticRoute.noStore()` - No storage allowed -```dart -pod.webServer.addRoute(RootRoute(), '/'); -pod.webServer.addRoute(RootRoute(), '/index.html'); -``` - -### Directory Structure - -For consistency with the new naming convention, we recommend renaming your `widgets/` directories to `components/`. However, this is not strictly required - the directory structure can remain unchanged if needed. - -### Class Names - -For consistency and clarity, we recommend updating all class names from "Widget" to "Component" (e.g., `MyPageWidget` → `MyPageComponent`). While you can keep your existing class names and only update the inheritance, following the new naming convention will make your code more maintainable and consistent with Serverpod's conventions. - -### Complete Example - -Here's a complete example of migrating a simple web page: - -**Before:** +You can also provide a custom factory function: ```dart -// lib/src/web/widgets/default_page_widget.dart -import 'package:serverpod/serverpod.dart'; - -class DefaultPageWidget extends Widget { - DefaultPageWidget() : super(name: 'default') { - values = { - 'served': DateTime.now(), - 'runmode': Serverpod.instance.runMode, - }; - } -} - -// lib/src/web/routes/root.dart -import 'dart:io'; -import 'package:my_server/src/web/widgets/default_page_widget.dart'; -import 'package:serverpod/serverpod.dart'; - -class RouteRoot extends WidgetRoute { - @override - Future build(Session session, HttpRequest request) async { - return DefaultPageWidget(); - } -} -``` - -**After:** - -```dart -// lib/src/web/components/default_page_component.dart (renamed file and directory) -import 'package:serverpod/serverpod.dart'; - -class DefaultPageComponent extends Component { - DefaultPageComponent() : super(name: 'default') { - values = { - 'served': DateTime.now(), - 'runmode': Serverpod.instance.runMode, - }; - } -} - -// lib/src/web/routes/root.dart -import 'dart:io'; -import 'package:my_server/src/web/components/default_page_component.dart'; -import 'package:serverpod/serverpod.dart'; - -class RootRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return DefaultPageComponent(); - } -} - -// server.dart -pod.webServer.addRoute(RootRoute(), '/'); -pod.webServer.addRoute(RootRoute(), '/index.html'); +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: (ctx, fileInfo) => CacheControlHeader( + publicCache: true, + maxAge: 3600, + immutable: true, + ), + ), + '/static/**', +); ``` From fd5b86df3d1188819f148d2182959c3f11607600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 10:29:17 +0100 Subject: [PATCH 02/39] docs: Add routing and middleware documentation Added detailed documentation for WebServer's advanced routing and middleware features, including: Routing: - Custom Route classes for REST APIs and custom response handling - HTTP method handling (GET, POST, PUT, DELETE, PATCH, etc.) - Path parameters and wildcard matching (*, **) - Fallback routes for 404 handling - Request object usage and query parameters Middleware: - Creating custom middleware functions - Authentication middleware patterns - CORS middleware implementation - Middleware execution order and path hierarchy - ContextProperty for request-scoped data - Built-in logRequests() middleware All examples are based on the Relic framework that WebServer is built on, with Serverpod-specific integration points highlighted. --- docs/06-concepts/18-webserver.md | 288 +++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 22183e93..f764eb0a 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -72,3 +72,291 @@ Static files are configured to be cached hard by the web browser and through Clo ## Database access and logging The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. + +## Advanced routing + +### Custom Route classes + +While `WidgetRoute` is convenient for rendering HTML pages, you can also create custom `Route` subclasses for more control over the response. This is useful for REST APIs, file downloads, or custom response handling. + +```dart +class ApiRoute extends Route { + ApiRoute() : super(methods: {Method.get, Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Access request method + if (request.method == Method.post) { + // Read request body + final body = await request.readAsString(); + final data = jsonDecode(body); + + // Process and return JSON response + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'success', 'data': data}), + mimeType: MimeType.json, + ), + ); + } + + // Return data for GET requests + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from API'}), + mimeType: MimeType.json, + ), + ); + } +} + +// Register the route +pod.webServer.addRoute(ApiRoute(), '/api/data'); +``` + +### HTTP methods + +Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: + +- `Method.get` - Retrieve data +- `Method.post` - Create new resources +- `Method.put` - Update resources (full replacement) +- `Method.patch` - Update resources (partial) +- `Method.delete` - Delete resources +- `Method.head` - Same as GET but without response body +- `Method.options` - Query supported methods (used for CORS) + +```dart +class UserRoute extends Route { + UserRoute() : super( + methods: {Method.get, Method.post, Method.delete}, + ); + + @override + Future handleCall(Session session, Request request) async { + switch (request.method) { + case Method.get: + return await _getUser(request); + case Method.post: + return await _createUser(request); + case Method.delete: + return await _deleteUser(request); + default: + return Response.methodNotAllowed(); + } + } +} +``` + +### Path parameters and wildcards + +Routes support path parameters and wildcard matching: + +```dart +// Single-level wildcard - matches /item/foo but not /item/foo/bar +pod.webServer.addRoute(ItemRoute(), '/item/*'); + +// Multi-level wildcard - matches /item/foo and /item/foo/bar/baz +pod.webServer.addRoute(ItemRoute(), '/item/**'); +``` + +Access the matched path information through the `Request` object: + +```dart +@override +Future handleCall(Session session, Request request) async { + // Get the remaining path after the route prefix + final remainingPath = request.url.path; + + // Access query parameters + final id = request.url.queryParameters['id']; + + return Response.ok( + body: Body.fromString('Path: $remainingPath, ID: $id'), + ); +} +``` + +### Fallback routes + +You can set a fallback route that handles requests when no other route matches: + +```dart +class NotFoundRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.notFound( + body: Body.fromString('Page not found: ${request.url.path}'), + ); + } +} + +// Set as fallback +pod.webServer.fallbackRoute = NotFoundRoute(); +``` + +## Middleware + +Middleware allows you to add cross-cutting functionality to your web server, such as authentication, logging, CORS handling, or request validation. Middleware functions wrap your route handlers, executing code before and after the request is processed. + +### Adding middleware + +Use the `addMiddleware` method to apply middleware to specific path prefixes: + +```dart +// Apply to all routes +pod.webServer.addMiddleware(loggingMiddleware, '/'); + +// Apply only to API routes +pod.webServer.addMiddleware(authMiddleware, '/api'); +``` + +### Creating custom middleware + +Middleware is a function that takes a `Handler` and returns a new `Handler`. Here's a simple logging middleware example: + +```dart +Handler loggingMiddleware(Handler innerHandler) { + return (Request request) async { + final start = DateTime.now(); + print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); + + // Call the next handler in the chain + final response = await innerHandler(request); + + final duration = DateTime.now().difference(start); + print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); + + return response; + }; +} +``` + +### Authentication middleware + +A common use case is adding authentication to protected routes: + +```dart +Handler authMiddleware(Handler innerHandler) { + return (Request request) async { + // Check for authentication token + final authHeader = request.headers.authorization; + + if (authHeader == null) { + return Response.unauthorized( + body: Body.fromString('Authentication required'), + ); + } + + // Verify token (simplified example) + final token = authHeader.headerValue; + if (!await verifyToken(token)) { + return Response.forbidden( + body: Body.fromString('Invalid token'), + ); + } + + // Continue to the next handler + return await innerHandler(request); + }; +} + +// Apply to protected routes +pod.webServer.addMiddleware(authMiddleware, '/admin'); +``` + +### CORS middleware + +Enable Cross-Origin Resource Sharing for your API: + +```dart +Handler corsMiddleware(Handler innerHandler) { + return (Request request) async { + // Handle preflight requests + if (request.method == Method.options) { + return Response.ok( + headers: Headers.build((h) { + h.set('Access-Control-Allow-Origin', '*'); + h.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); + h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + }), + ); + } + + // Process the request + final response = await innerHandler(request); + + // Add CORS headers to response + return response.change( + headers: Headers.build((h) { + h.set('Access-Control-Allow-Origin', '*'); + }), + ); + }; +} + +pod.webServer.addMiddleware(corsMiddleware, '/api'); +``` + +### Middleware execution order + +Middleware is applied based on path hierarchy, with more specific paths taking precedence. Within the same path, middleware executes in the order it was registered: + +```dart +pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) +pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes +pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes +``` + +For a request to `/api/users`, the execution order is: +1. `loggingMiddleware` (before) +2. `authMiddleware` (before) +3. `rateLimitMiddleware` (before) +4. Your route handler +5. `rateLimitMiddleware` (after) +6. `authMiddleware` (after) +7. `loggingMiddleware` (after) + +### Using ContextProperty for request-scoped data + +Instead of modifying the request object, use `ContextProperty` to attach data that middleware or routes can access: + +```dart +final userProperty = ContextProperty(); + +Handler authMiddleware(Handler innerHandler) { + return (Request request) async { + final token = request.headers.authorization?.headerValue; + final user = await getUserFromToken(token); + + // Attach user to request context + userProperty[request] = user; + + return await innerHandler(request); + }; +} + +// Access in your route +class UserProfileRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final user = userProperty[request]; // Get the authenticated user + + return Response.ok( + body: Body.fromString('Hello, ${user.name}!'), + ); + } +} +``` + +### Built-in logging middleware + +Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient request logging: + +```dart +import 'package:serverpod/serverpod.dart'; + +pod.webServer.addMiddleware(logRequests(), '/'); +``` + +This logs all requests with method, path, status code, and response time. From 69a2a83da3bcac1d011250f9e1e595fb2a40a28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 10:40:42 +0100 Subject: [PATCH 03/39] docs: Add modular route documentation with injectIn override Added comprehensive documentation for creating modular routes by overriding the injectIn method, including: - Detailed path parameters section with :paramName syntax - How to extract and validate path parameters using symbols (pathParameters[#id]) - Multiple path parameters in a single route - Complete CRUD module example using proper RESTful path parameters - Path parameter validation and error handling - Wildcards section (single-level * and multi-level **) - Pattern for composing multiple sub-modules into a parent module - Best practices for organizing complex APIs The injectIn override pattern enables: - Hierarchical route organization - Reusable route modules - Complex API composition from smaller focused modules - Separation of route registration from handler implementation Examples show practical RESTful API patterns: - GET /api/users - List all - GET /api/users/:id - Get by ID (access via pathParameters[#id]) - POST /api/users - Create - PUT /api/users/:id - Update by ID - DELETE /api/users/:id - Delete by ID All examples include proper path parameter extraction using symbols, validation, and error handling with appropriate HTTP status codes. --- docs/06-concepts/18-webserver.md | 288 ++++++++++++++++++++++++++++++- 1 file changed, 284 insertions(+), 4 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index f764eb0a..8fcae1be 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -148,9 +148,69 @@ class UserRoute extends Route { } ``` -### Path parameters and wildcards +### Path parameters -Routes support path parameters and wildcard matching: +Routes support named path parameters using the `:paramName` syntax. These are automatically extracted and made available through the `Request` object: + +```dart +class UserRoute extends Route { + UserRoute() : super(methods: {Method.get}); + + @override + void injectIn(RelicRouter router) { + // Define route with path parameter + router.get('/:id', asHandler); + } + + @override + Future handleCall(Session session, Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound(); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } +} + +// Register at /api/users - will match /api/users/123 +pod.webServer.addRoute(UserRoute(), '/api/users'); +``` + +You can use multiple path parameters in a single route: + +```dart +router.get('/:userId/posts/:postId', handler); +// Matches: /123/posts/456 +// request.pathParameters[#userId] => '123' +// request.pathParameters[#postId] => '456' +``` + +### Wildcards + +Routes also support wildcard matching for catching all paths: ```dart // Single-level wildcard - matches /item/foo but not /item/foo/bar @@ -169,10 +229,10 @@ Future handleCall(Session session, Request request) async { final remainingPath = request.url.path; // Access query parameters - final id = request.url.queryParameters['id']; + final query = request.url.queryParameters['query']; return Response.ok( - body: Body.fromString('Path: $remainingPath, ID: $id'), + body: Body.fromString('Path: $remainingPath, Query: $query'), ); } ``` @@ -195,6 +255,226 @@ class NotFoundRoute extends Route { pod.webServer.fallbackRoute = NotFoundRoute(); ``` +### Modular routes with injectIn + +For complex applications, you can create modular route classes that register multiple sub-routes by overriding the `injectIn` method. This allows you to organize related routes into reusable modules. + +When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for the specified path. By overriding `injectIn`, you can register multiple routes instead of just one. + +#### Creating a CRUD module + +Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get) + ..post('/', _create) + ..put('/:id', _update) + ..delete('/:id', _delete); + } + + // Handler methods + Future _list(Request request) async { + final session = request.session; + final users = await User.db.find(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } + + Future _get(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _create(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = User(name: data['name'], email: data['email']); + await User.db.insertRow(session, user); + + return Response.created( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _update(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = await User.db.findById(session, userId); + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + user.name = data['name'] ?? user.name; + user.email = data['email'] ?? user.email; + await User.db.updateRow(session, user); + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _delete(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final deleted = await User.db.deleteRow(session, userId); + + if (!deleted) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.noContent(); + } + + // Required by Route but not used since we override injectIn + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire CRUD module under /api/users +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` + +This creates the following RESTful endpoints: +- `GET /api/users` - List all users +- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) +- `POST /api/users` - Create a new user +- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) +- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) + +:::tip +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. Always validate and parse these values since they come from user input as strings. +::: + +#### Composing multiple modules + +You can create a parent module that composes multiple sub-modules: + +```dart +class ApiModule extends Route { + @override + void injectIn(RelicRouter router) { + // Inject sub-modules at different paths + router.group('/users').inject(UserCrudModule()); + router.group('/posts').inject(PostCrudModule()); + router.group('/comments').inject(CommentCrudModule()); + + // Add module-level routes + router.get('/health', _healthCheck); + } + + Future _healthCheck(Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), + mimeType: MimeType.json, + ), + ); + } + + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire API module +pod.webServer.addRoute(ApiModule(), '/api'); +``` + +This pattern enables you to: +- **Organize routes hierarchically** - Group related functionality together +- **Reuse route modules** - Use the same module in different applications +- **Compose complex APIs** - Build large APIs from smaller, focused modules +- **Separate concerns** - Keep route registration logic separate from handler implementation + +:::tip +When overriding `injectIn`, you typically don't need to implement `handleCall` since you're registering handler functions directly with the router. You can throw `UnimplementedError` in `handleCall` to make it clear the method isn't used. +::: + ## Middleware Middleware allows you to add cross-cutting functionality to your web server, such as authentication, logging, CORS handling, or request validation. Middleware functions wrap your route handlers, executing code before and after the request is processed. From dbcd65ee2e918db1c81de16f12eebd9ff2a9477c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 10:50:17 +0100 Subject: [PATCH 04/39] docs: Add comprehensive ContextProperty documentation Expanded the ContextProperty section with detailed documentation on using context properties for request-scoped data: Overview: - Why use ContextProperty instead of modifying Request - Common use cases (auth, rate limiting, request IDs, multi-tenant, feature flags) Creating and Using: - How to define ContextProperty instances - Setting values in middleware - Accessing values in routes - Safe access with getOrNull() - Using default values Complete Examples: - Full authentication middleware with bearer token validation - Role-based authorization middleware (requireRole) - Multi-tenant application with multiple context properties - Request ID tracking and logging - Combining multiple properties in routes Best Practices: - Define as top-level constants or static fields - Use descriptive naming conventions - Set in middleware, read in routes - Use getOrNull() for optional values - Leverage type safety This pattern is essential for building production-ready web applications with proper separation of concerns between authentication, authorization, and business logic. --- docs/06-concepts/18-webserver.md | 248 ++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 6 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 8fcae1be..6dd66529 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -597,38 +597,274 @@ For a request to `/api/users`, the execution order is: 6. `authMiddleware` (after) 7. `loggingMiddleware` (after) -### Using ContextProperty for request-scoped data +### Request-scoped data with ContextProperty -Instead of modifying the request object, use `ContextProperty` to attach data that middleware or routes can access: +`ContextProperty` provides a type-safe way to attach data to a `Request` object that can be accessed by downstream middleware and route handlers. This is the recommended pattern for passing computed or authenticated data through your request pipeline. + +#### Why use ContextProperty? + +Instead of modifying the `Request` object directly (which you can't do since it's immutable), `ContextProperty` allows you to associate additional data with a request. Common use cases include: + +- **Authentication** - Attach the authenticated user to the request +- **Rate limiting** - Store rate limit state per request +- **Request ID tracking** - Add correlation IDs for logging +- **Tenant identification** - Multi-tenant application context +- **Feature flags** - Request-specific feature toggles + +#### Creating a ContextProperty + +Define a `ContextProperty` as a top-level constant or static field: + +```dart +// Define a property for the authenticated user +final userProperty = ContextProperty(); + +// Define a property for request ID +final requestIdProperty = ContextProperty(); + +// Optional: with a default value +final featureFlagsProperty = ContextProperty( + defaultValue: () => FeatureFlags.defaults(), +); +``` + +#### Setting values in middleware + +Middleware can set values on the context property, making them available to all downstream handlers: ```dart -final userProperty = ContextProperty(); +final userProperty = ContextProperty(); Handler authMiddleware(Handler innerHandler) { return (Request request) async { + // Extract and verify token final token = request.headers.authorization?.headerValue; + + if (token == null) { + return Response.unauthorized( + body: Body.fromString('Authentication required'), + ); + } + + // Validate token and get user info final user = await getUserFromToken(token); + if (user == null) { + return Response.forbidden( + body: Body.fromString('Invalid token'), + ); + } + // Attach user to request context userProperty[request] = user; + // Continue to next handler with user attached return await innerHandler(request); }; } +``` + +#### Accessing values in routes -// Access in your route +Route handlers can retrieve the value from the context property: + +```dart class UserProfileRoute extends Route { @override Future handleCall(Session session, Request request) async { - final user = userProperty[request]; // Get the authenticated user + // Get the authenticated user from context + final user = userProperty[request]; return Response.ok( - body: Body.fromString('Hello, ${user.name}!'), + body: Body.fromString( + jsonEncode({ + 'id': user.id, + 'name': user.name, + 'email': user.email, + }), + mimeType: MimeType.json, + ), ); } } ``` +#### Safe access with getOrNull + +If a value might not be set, use `getOrNull()` to avoid exceptions: + +```dart +class OptionalAuthRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Safely get user, returns null if not authenticated + final user = userProperty.getOrNull(request); + + if (user != null) { + return Response.ok( + body: Body.fromString('Hello, ${user.name}!'), + ); + } else { + return Response.ok( + body: Body.fromString('Hello, guest!'), + ); + } + } +} +``` + +#### Complete authentication example + +Here's a complete example showing authentication middleware with context properties: + +```dart +// Define the user info class +class UserInfo { + final int id; + final String name; + final String email; + final List roles; + + UserInfo({ + required this.id, + required this.name, + required this.email, + required this.roles, + }); +} + +// Define the context property +final userProperty = ContextProperty(); + +// Authentication middleware +Handler authMiddleware(Handler innerHandler) { + return (Request request) async { + final authHeader = request.headers.authorization; + + if (authHeader == null) { + return Response.unauthorized( + body: Body.fromString('Missing authorization header'), + ); + } + + // Extract bearer token + final token = authHeader.headerValue; + if (!token.startsWith('Bearer ')) { + return Response.unauthorized( + body: Body.fromString('Invalid authorization format'), + ); + } + + final bearerToken = token.substring(7); + + // Validate token and get user (implement your own logic) + final session = request.session; + final user = await validateTokenAndGetUser(session, bearerToken); + + if (user == null) { + return Response.forbidden( + body: Body.fromString('Invalid or expired token'), + ); + } + + // Attach user to context + userProperty[request] = user; + + return await innerHandler(request); + }; +} + +// Role-checking middleware +Handler requireRole(String role) { + return (Handler innerHandler) { + return (Request request) async { + final user = userProperty[request]; + + if (!user.roles.contains(role)) { + return Response.forbidden( + body: Body.fromString('Insufficient permissions'), + ); + } + + return await innerHandler(request); + }; + }; +} + +// Usage in your server +pod.webServer.addMiddleware(authMiddleware, '/api'); +pod.webServer.addMiddleware(requireRole('admin'), '/api/admin'); + +// Routes automatically have access to the user +class UserDashboardRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final user = userProperty[request]; + + // Fetch user-specific data + final data = await fetchDashboardData(session, user.id); + + return Response.ok( + body: Body.fromString( + jsonEncode(data), + mimeType: MimeType.json, + ), + ); + } +} +``` + +#### Multiple context properties + +You can use multiple context properties for different types of data: + +```dart +final userProperty = ContextProperty(); +final requestIdProperty = ContextProperty(); +final tenantProperty = ContextProperty(); + +Handler requestContextMiddleware(Handler innerHandler) { + return (Request request) async { + // Generate and attach request ID + final requestId = Uuid().v4(); + requestIdProperty[request] = requestId; + + // Extract tenant from subdomain or header + final tenant = extractTenant(request); + tenantProperty[request] = tenant; + + return await innerHandler(request); + }; +} + +// Later in your route +class TenantDataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final user = userProperty[request]; + final requestId = requestIdProperty[request]; + final tenant = tenantProperty[request]; + + session.log('Request $requestId for tenant $tenant by user ${user.id}'); + + // Fetch tenant-specific data + final data = await fetchTenantData(session, tenant, user.id); + + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } +} +``` + +:::tip Best Practices +- Define `ContextProperty` instances as top-level constants or static fields +- Use descriptive names for your properties (e.g., `userProperty`, not just `user`) +- Use `getOrNull()` when the value might not be set +- Set properties in middleware, not in routes +- Use specific types for better type safety +::: + ### Built-in logging middleware Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient request logging: From a70822f7d6205039cde031c3de34c9d239db4fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 10:56:42 +0100 Subject: [PATCH 05/39] docs: Add typed headers documentation --- docs/06-concepts/18-webserver.md | 324 +++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 6dd66529..fb532b2e 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -876,3 +876,327 @@ pod.webServer.addMiddleware(logRequests(), '/'); ``` This logs all requests with method, path, status code, and response time. + +## Typed headers + +Serverpod's web server (via Relic) provides a type-safe header system that goes beyond simple string-based HTTP headers. Instead of working with raw strings, you can access and set HTTP headers using strongly-typed Dart objects with automatic parsing and validation. + +### Reading typed headers + +Access typed headers through extension methods on `request.headers`: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Type-safe accessors return parsed objects or null + final auth = request.headers.authorization; // AuthorizationHeader? + final contentType = request.headers.contentType; // ContentTypeHeader? + final cookies = request.headers.cookie; // CookieHeader? + final userAgent = request.headers.userAgent; // String? + final host = request.headers.host; // HostHeader? + + // Raw access still available for custom headers + final custom = request.headers['X-Custom-Header']; // Iterable? + + return Response.ok(); + } +} +``` + +Common request headers include: +- `authorization` - AuthorizationHeader (Bearer/Basic/Digest) +- `contentType` - ContentTypeHeader +- `contentLength` - int +- `cookie` - CookieHeader +- `accept` - AcceptHeader (media types) +- `acceptEncoding` - AcceptEncodingHeader +- `acceptLanguage` - AcceptLanguageHeader +- `userAgent` - String +- `host` - HostHeader +- `referer` - Uri +- `origin` - Uri + +### Setting typed headers + +Set typed headers in responses using the `Headers.build()` builder pattern: + +```dart +return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, + publicCache: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + + // Set custom headers (raw) + h['X-API-Version'] = ['2.0']; + }), + body: Body.fromString(jsonEncode(data)), +); +``` + +Common response headers include: +- `cacheControl` - CacheControlHeader +- `setCookie` - SetCookieHeader +- `location` - Uri +- `contentDisposition` - ContentDispositionHeader +- `etag` - ETagHeader +- `vary` - VaryHeader + +### AuthorizationHeader - Authentication + +The `AuthorizationHeader` supports three authentication schemes: + +**Bearer Token (JWT, OAuth):** +```dart +final auth = request.headers.authorization; + +if (auth is BearerAuthorizationHeader) { + final token = auth.token; // The actual token string + + // Validate token + if (!await validateToken(token)) { + return Response.unauthorized(); + } +} +``` + +**Basic Authentication:** +```dart +if (auth is BasicAuthorizationHeader) { + final username = auth.username; + final password = auth.password; + + // Validate credentials + if (!await validateCredentials(username, password)) { + return Response.unauthorized(); + } +} +``` + +**Setting Bearer token:** +```dart +headers: Headers.build((h) { + h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); +}), +``` + +### CacheControlHeader - Cache directives + +Control caching behavior with type-safe cache directives: + +```dart +// Public cache with 1 hour expiration +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, // Cache for 1 hour + publicCache: true, // Shared cache allowed + mustRevalidate: true, // Must revalidate after expiry + staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating + ); +}), +``` + +```dart +// Secure defaults for sensitive data +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + noStore: true, // Don't store anywhere + noCache: true, // Must revalidate + privateCache: true, // Only private cache + ); +}), +``` + +Available directives: +- `noCache`, `noStore` - Cache control flags +- `maxAge`, `sMaxAge` - Seconds of freshness +- `mustRevalidate`, `proxyRevalidate` - Revalidation requirements +- `publicCache`, `privateCache` - Cache scope +- `staleWhileRevalidate`, `staleIfError` - Stale caching +- `immutable` - Content never changes + +### ContentDispositionHeader - File downloads + +Specify how content should be handled (inline display or download): + +```dart +// File download with proper filename +headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter(name: 'filename', value: 'report.pdf'), + ], + ); +}), +``` + +```dart +// With extended encoding (RFC 5987) for non-ASCII filenames +h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: 'rapport.pdf', + isExtended: true, + encoding: 'UTF-8', + ), + ], +); +``` + +### CookieHeader and SetCookieHeader - Cookies + +**Reading cookies from requests:** +```dart +final cookieHeader = request.headers.cookie; + +if (cookieHeader != null) { + // Find a specific cookie + final sessionId = cookieHeader.getCookie('session_id')?.value; + + // Iterate all cookies + for (final cookie in cookieHeader.cookies) { + print('${cookie.name}=${cookie.value}'); + } +} +``` + +**Setting cookies in responses:** +```dart +headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: '12345abcde', + maxAge: 3600, // 1 hour + path: Uri.parse('/'), + domain: Uri.parse('example.com'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.strict, // CSRF protection + ); +}), +``` + +SameSite values: +- `SameSite.lax` - Default, not sent on cross-site requests (except navigation) +- `SameSite.strict` - Never sent on cross-site requests +- `SameSite.none` - Sent on all requests (requires `secure: true`) + +### Complete examples + +**Secure API with authentication and caching:** +```dart +class SecureApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Check authorization + final auth = request.headers.authorization; + if (auth is! BearerAuthorizationHeader) { + return Response.unauthorized(); + } + + // Validate token + if (!await validateToken(auth.token)) { + return Response.forbidden(); + } + + // Return data with cache headers + return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 300, // 5 minutes + publicCache: true, + mustRevalidate: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + }), + body: Body.fromString(jsonEncode(data)), + ); + } +} +``` + +**File download with proper headers:** +```dart +class DownloadRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final fileId = request.pathParameters[#fileId]; + final file = await getFile(session, fileId); + + return Response.ok( + headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: file.name, + isExtended: true, + encoding: 'UTF-8', + ), + ], + ); + h.contentType = ContentTypeHeader( + mimeType: file.mimeType, + ); + h.cacheControl = CacheControlHeader( + noCache: true, + mustRevalidate: true, + ); + }), + body: Body.fromBytes(file.content), + ); + } +} +``` + +**Cookie-based sessions:** +```dart +class LoginRoute extends Route { + LoginRoute() : super(methods: {Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Authenticate user... + final sessionToken = await authenticateAndCreateSession(session, request); + + return Response.ok( + headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: sessionToken, + maxAge: 86400, // 24 hours + path: Uri.parse('/'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.lax, // CSRF protection + ); + }), + body: Body.fromString( + jsonEncode({'status': 'logged_in'}), + mimeType: MimeType.json, + ), + ); + } +} +``` + +:::tip Best Practices +- Use typed headers for automatic parsing and validation +- Set appropriate cache headers for better performance +- Use `secure: true` and `httpOnly: true` for sensitive cookies +- Set proper `ContentDisposition` headers for file downloads +- Use `SameSite` cookie attribute for CSRF protection +::: From 094db229e7488d5d7a1326787ae04add8f78cefe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:00:05 +0100 Subject: [PATCH 06/39] docs: Add custom typed headers documentation --- docs/06-concepts/18-webserver.md | 146 +++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index fb532b2e..743e0416 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -1200,3 +1200,149 @@ class LoginRoute extends Route { - Set proper `ContentDisposition` headers for file downloads - Use `SameSite` cookie attribute for CSRF protection ::: + +### Creating custom typed headers + +You can create your own typed headers by defining a header class and a `HeaderAccessor`. Here's a simple example for a custom `X-API-Version` header: + +```dart +// Define your typed header class +final class ApiVersionHeader { + final int major; + final int minor; + + ApiVersionHeader({required this.major, required this.minor}); + + // Parse from string format "1.2" + factory ApiVersionHeader.parse(String value) { + final parts = value.split('.'); + if (parts.length != 2) { + throw const FormatException('Invalid API version format'); + } + + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); + + if (major == null || minor == null) { + throw const FormatException('Invalid API version numbers'); + } + + return ApiVersionHeader(major: major, minor: minor); + } + + // Encode to string format + String encode() => '$major.$minor'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApiVersionHeader && + major == other.major && + minor == other.minor; + + @override + int get hashCode => Object.hash(major, minor); + + @override + String toString() => 'ApiVersionHeader($major.$minor)'; +} + +// Create a HeaderCodec for encoding/decoding +const _apiVersionCodec = HeaderCodec.single( + ApiVersionHeader.parse, + (ApiVersionHeader value) => [value.encode()], +); + +// Create a global HeaderAccessor +const apiVersionHeader = HeaderAccessor( + 'x-api-version', + _apiVersionCodec, +); +``` + +**Using your custom header:** + +```dart +// Reading the header +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final version = apiVersionHeader[request.headers](); + + if (version != null && version.major < 2) { + return Response.badRequest( + body: Body.fromString('API version 2.0 or higher required'), + ); + } + + return Response.ok(); + } +} + +// Setting the header +return Response.ok( + headers: Headers.build((h) { + apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); + }), +); +``` + +**Multi-value header example:** + +For headers that can have multiple comma-separated values: + +```dart +final class CustomTagsHeader { + final List tags; + + CustomTagsHeader({required List tags}) + : tags = List.unmodifiable(tags); + + // Parse from multiple values or comma-separated + factory CustomTagsHeader.parse(Iterable values) { + final allTags = values + .expand((v) => v.split(',')) + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toSet() + .toList(); + + if (allTags.isEmpty) { + throw const FormatException('Tags cannot be empty'); + } + + return CustomTagsHeader(tags: allTags); + } + + List encode() => [tags.join(', ')]; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CustomTagsHeader && + const ListEquality().equals(tags, other.tags); + + @override + int get hashCode => const ListEquality().hash(tags); +} + +// Use HeaderCodec (not HeaderCodec.single) for multi-value +const _customTagsCodec = HeaderCodec( + CustomTagsHeader.parse, + (CustomTagsHeader value) => value.encode(), +); + +const customTagsHeader = HeaderAccessor( + 'x-custom-tags', + _customTagsCodec, +); +``` + +**Key points:** + +- Use `HeaderCodec.single()` when your header has only one value +- Use `HeaderCodec()` when your header can have multiple comma-separated values +- Define the `HeaderAccessor` as a global `const` +- Throw `FormatException` for invalid header values +- Implement `==` and `hashCode` for value equality +- The `HeaderAccessor` automatically caches parsed values for performance From 7af9bde784787952b4243c4090be418c1fd9e0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:01:45 +0100 Subject: [PATCH 07/39] docs: Add extension methods for custom typed headers --- docs/06-concepts/18-webserver.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 743e0416..72680bc4 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -1338,6 +1338,38 @@ const customTagsHeader = HeaderAccessor( ); ``` +**Optional: Add extension methods for convenient access** + +For better ergonomics, you can add extension methods to access your custom headers with property syntax: + +```dart +extension CustomHeadersEx on Headers { + ApiVersionHeader? get apiVersion => apiVersionHeader[this](); + CustomTagsHeader? get customTags => customTagsHeader[this](); +} + +extension CustomMutableHeadersEx on MutableHeaders { + set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); + set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); +} +``` + +Now you can use property syntax instead of the bracket notation: + +```dart +// Reading with property syntax +final version = request.headers.apiVersion; +final tags = request.headers.customTags; + +// Setting with property syntax +return Response.ok( + headers: Headers.build((h) { + h.apiVersion = ApiVersionHeader(major: 2, minor: 1); + h.customTags = CustomTagsHeader(tags: ['production', 'v2']); + }), +); +``` + **Key points:** - Use `HeaderCodec.single()` when your header has only one value @@ -1346,3 +1378,4 @@ const customTagsHeader = HeaderAccessor( - Throw `FormatException` for invalid header values - Implement `==` and `hashCode` for value equality - The `HeaderAccessor` automatically caches parsed values for performance +- Optionally add extension methods for convenient property-style access From 205a0cd74cfc2968e2a29b0d6be7cc0e75d1532c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:16:58 +0100 Subject: [PATCH 08/39] docs: Clarify /** is tail-match wildcard with O(h) performance guarantee --- docs/06-concepts/18-webserver.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 72680bc4..9ddd7663 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -216,10 +216,14 @@ Routes also support wildcard matching for catching all paths: // Single-level wildcard - matches /item/foo but not /item/foo/bar pod.webServer.addRoute(ItemRoute(), '/item/*'); -// Multi-level wildcard - matches /item/foo and /item/foo/bar/baz +// Tail-match wildcard - matches /item/foo and /item/foo/bar/baz pod.webServer.addRoute(ItemRoute(), '/item/**'); ``` +:::info Performance Guarantee +The `/**` wildcard is a **tail-match** pattern and can only appear at the end of a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. This design ensures O(h) route lookup performance, where h is the path length, without backtracking. This keeps routing fast and predictable, even with many routes. +::: + Access the matched path information through the `Request` object: ```dart From d1f65c3b7940e58ec37562a939adff2faf5d61e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:19:28 +0100 Subject: [PATCH 09/39] docs: Clarify raw header access works for all headers, not just custom ones --- docs/06-concepts/18-webserver.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 9ddd7663..cd0db892 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -900,7 +900,8 @@ class ApiRoute extends Route { final userAgent = request.headers.userAgent; // String? final host = request.headers.host; // HostHeader? - // Raw access still available for custom headers + // Raw string access is also available for any header + final authRaw = request.headers['Authorization']; // Iterable? final custom = request.headers['X-Custom-Header']; // Iterable? return Response.ok(); From 0338cc5f4fe296f6b51a87d44db77eaf31121e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:28:14 +0100 Subject: [PATCH 10/39] docs: Update cache-busting documentation with CacheBustingConfig --- docs/06-concepts/18-webserver.md | 64 ++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index cd0db892..f8b786df 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -63,11 +63,69 @@ Serverpod comes with a few useful special widgets and routes you can use out of To serve a static directory, use the `StaticRoute.directory()` method. Serverpod will set the correct content types for most file types automatically. -:::caution +### Static file cache-busting -Static files are configured to be cached hard by the web browser and through Cloudfront's content delivery network (if you use the AWS deployment). If you change static files, they will need to be renamed, or users will most likely access old files. To make this easier, you can add a version number when referencing the static files. The version number will be ignored when looking up the actual file. E.g., `/static/my_image@v42.png` will serve to the `/static/my_image.png` file. More advanced cache management will be coming to a future version of Serverpod. +When deploying static assets, browsers and CDNs (like CloudFront) cache files aggressively for performance. This means updated files may not be served to users unless you implement a cache-busting strategy. -::: +Serverpod provides `CacheBustingConfig` to automatically version your static files: + +```dart +final staticDir = Directory('web/static'); + +final cacheBustingConfig = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticDir, + separator: '@', // or use custom separator like '___' +); + +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), + ), + '/static/**', +); +``` + +**Generating versioned URLs:** + +Use the `assetPath()` method to generate cache-busted URLs for your assets: + +```dart +// In your route handler +final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); +// Returns: /static/logo@.png + +// Pass to your template +return MyPageWidget(logoUrl: imageUrl); +``` + +The cache-busting system: +- Automatically generates content-based hashes for asset versioning +- Allows custom separators (default `@`, but you can use `___` or any other) +- Preserves file extensions +- Works transparently - requesting `/static/logo@abc123.png` serves `/static/logo.png` + +**Combining with cache control:** + +For optimal performance, combine cache-busting with aggressive caching: + +```dart +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year + ), + '/static/**', +); +``` + +This approach ensures: +- Browsers cache files for a long time (better performance) +- When files change, new hashes force cache invalidation +- No manual version management needed ## Database access and logging From 7b8141ed35df7d452e76739ba2931791c25756fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:29:19 +0100 Subject: [PATCH 11/39] docs: Document automatic ETag and Last-Modified support in StaticRoute --- docs/06-concepts/18-webserver.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index f8b786df..c5530338 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -127,6 +127,37 @@ This approach ensures: - When files change, new hashes force cache invalidation - No manual version management needed +### Conditional requests (ETags and Last-Modified) + +`StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file content when unchanged: + +**Supported features:** +- **ETag headers** - Content-based fingerprinting for cache validation +- **Last-Modified headers** - Timestamp-based cache validation +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not modified + +These work automatically without configuration: + +```dart +// Client first request: +// GET /static/logo.png +// Response: 200 OK +// ETag: "abc123" +// Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT +// Content-Length: 12345 +// [file content] + +// Client subsequent request: +// GET /static/logo.png +// If-None-Match: "abc123" +// Response: 304 Not Modified +// ETag: "abc123" +// [no body - saves bandwidth] +``` + +When combined with cache-busting, conditional requests provide a fallback validation mechanism even for cached assets, ensuring efficient delivery while maintaining correctness. + ## Database access and logging The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. From dba44cf1c4fcd522c9bec4ced74bbea7cbbb9022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:33:30 +0100 Subject: [PATCH 12/39] docs: Replace authentication examples with better ContextProperty use cases - Added note that Session already provides auth info - Changed examples to focus on request ID tracking, tenant identification, and API versioning - Removed redundant authentication middleware examples - Better demonstrates web-specific use cases for ContextProperty --- docs/06-concepts/18-webserver.md | 210 ++++++++++++++----------------- 1 file changed, 95 insertions(+), 115 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index c5530338..3ecfb099 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -692,29 +692,33 @@ For a request to `/api/users`, the execution order is: ### Request-scoped data with ContextProperty -`ContextProperty` provides a type-safe way to attach data to a `Request` object that can be accessed by downstream middleware and route handlers. This is the recommended pattern for passing computed or authenticated data through your request pipeline. +`ContextProperty` provides a type-safe way to attach data to a `Request` object that can be accessed by downstream middleware and route handlers. This is the recommended pattern for passing computed data through your request pipeline. + +:::info +Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard Session. +::: #### Why use ContextProperty? Instead of modifying the `Request` object directly (which you can't do since it's immutable), `ContextProperty` allows you to associate additional data with a request. Common use cases include: -- **Authentication** - Attach the authenticated user to the request +- **Request ID tracking** - Add correlation IDs for logging and tracing +- **Tenant identification** - Multi-tenant application context from subdomains/headers +- **Feature flags** - Request-specific feature toggles based on headers/cookies - **Rate limiting** - Store rate limit state per request -- **Request ID tracking** - Add correlation IDs for logging -- **Tenant identification** - Multi-tenant application context -- **Feature flags** - Request-specific feature toggles +- **API versioning** - Extract and store API version from headers #### Creating a ContextProperty Define a `ContextProperty` as a top-level constant or static field: ```dart -// Define a property for the authenticated user -final userProperty = ContextProperty(); - -// Define a property for request ID +// Define a property for request ID tracking final requestIdProperty = ContextProperty(); +// Define a property for tenant identification +final tenantProperty = ContextProperty(); + // Optional: with a default value final featureFlagsProperty = ContextProperty( defaultValue: () => FeatureFlags.defaults(), @@ -726,33 +730,29 @@ final featureFlagsProperty = ContextProperty( Middleware can set values on the context property, making them available to all downstream handlers: ```dart -final userProperty = ContextProperty(); +final requestIdProperty = ContextProperty(); -Handler authMiddleware(Handler innerHandler) { +Handler requestIdMiddleware(Handler innerHandler) { return (Request request) async { - // Extract and verify token - final token = request.headers.authorization?.headerValue; + // Generate a unique request ID for tracing + final requestId = Uuid().v4(); - if (token == null) { - return Response.unauthorized( - body: Body.fromString('Authentication required'), - ); - } + // Attach to request context + requestIdProperty[request] = requestId; - // Validate token and get user info - final user = await getUserFromToken(token); + // Log the incoming request + print('[$requestId] ${request.method} ${request.url.path}'); - if (user == null) { - return Response.forbidden( - body: Body.fromString('Invalid token'), - ); - } + // Continue to next handler + final response = await innerHandler(request); - // Attach user to request context - userProperty[request] = user; + // Log the response + print('[$requestId] Response: ${response.statusCode}'); - // Continue to next handler with user attached - return await innerHandler(request); + // Optionally add request ID to response headers + return response.change( + headers: {'X-Request-ID': requestId}, + ); }; } ``` @@ -762,19 +762,21 @@ Handler authMiddleware(Handler innerHandler) { Route handlers can retrieve the value from the context property: ```dart -class UserProfileRoute extends Route { +class ApiRoute extends Route { @override Future handleCall(Session session, Request request) async { - // Get the authenticated user from context - final user = userProperty[request]; + // Get the request ID from context + final requestId = requestIdProperty[request]; + + // Use it for logging or tracing + session.log('Processing request $requestId'); + + // Your route logic here + final data = await processRequest(session); return Response.ok( body: Body.fromString( - jsonEncode({ - 'id': user.id, - 'name': user.name, - 'email': user.email, - }), + jsonEncode(data), mimeType: MimeType.json, ), ); @@ -787,119 +789,90 @@ class UserProfileRoute extends Route { If a value might not be set, use `getOrNull()` to avoid exceptions: ```dart -class OptionalAuthRoute extends Route { +class TenantRoute extends Route { @override Future handleCall(Session session, Request request) async { - // Safely get user, returns null if not authenticated - final user = userProperty.getOrNull(request); + // Safely get tenant, returns null if not set + final tenant = tenantProperty.getOrNull(request); - if (user != null) { + if (tenant != null) { + // Fetch tenant-specific data + final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); return Response.ok( - body: Body.fromString('Hello, ${user.name}!'), + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), ); } else { - return Response.ok( - body: Body.fromString('Hello, guest!'), + return Response.badRequest( + body: Body.fromString('Missing tenant identifier'), ); } } } ``` -#### Complete authentication example +#### Complete multi-tenant example -Here's a complete example showing authentication middleware with context properties: +Here's a complete example showing tenant identification from subdomains: ```dart -// Define the user info class -class UserInfo { - final int id; - final String name; - final String email; - final List roles; - - UserInfo({ - required this.id, - required this.name, - required this.email, - required this.roles, - }); -} - -// Define the context property -final userProperty = ContextProperty(); +// Define the context property for tenant ID +final tenantProperty = ContextProperty(); -// Authentication middleware -Handler authMiddleware(Handler innerHandler) { +// Tenant identification middleware (extracts from subdomain) +Handler tenantMiddleware(Handler innerHandler) { return (Request request) async { - final authHeader = request.headers.authorization; + final host = request.headers.host; - if (authHeader == null) { - return Response.unauthorized( - body: Body.fromString('Missing authorization header'), + if (host == null) { + return Response.badRequest( + body: Body.fromString('Missing host header'), ); } - // Extract bearer token - final token = authHeader.headerValue; - if (!token.startsWith('Bearer ')) { - return Response.unauthorized( - body: Body.fromString('Invalid authorization format'), + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") + final parts = host.host.split('.'); + if (parts.length < 2) { + return Response.badRequest( + body: Body.fromString('Invalid hostname format'), ); } - final bearerToken = token.substring(7); + final tenant = parts.first; - // Validate token and get user (implement your own logic) + // Validate tenant exists (implement your own logic) final session = request.session; - final user = await validateTokenAndGetUser(session, bearerToken); + final tenantExists = await validateTenant(session, tenant); - if (user == null) { - return Response.forbidden( - body: Body.fromString('Invalid or expired token'), + if (!tenantExists) { + return Response.notFound( + body: Body.fromString('Tenant not found'), ); } - // Attach user to context - userProperty[request] = user; + // Attach tenant to context + tenantProperty[request] = tenant; return await innerHandler(request); }; } -// Role-checking middleware -Handler requireRole(String role) { - return (Handler innerHandler) { - return (Request request) async { - final user = userProperty[request]; - - if (!user.roles.contains(role)) { - return Response.forbidden( - body: Body.fromString('Insufficient permissions'), - ); - } - - return await innerHandler(request); - }; - }; -} - // Usage in your server -pod.webServer.addMiddleware(authMiddleware, '/api'); -pod.webServer.addMiddleware(requireRole('admin'), '/api/admin'); +pod.webServer.addMiddleware(tenantMiddleware, '/'); -// Routes automatically have access to the user -class UserDashboardRoute extends Route { +// Routes automatically have access to the tenant +class TenantDataRoute extends Route { @override Future handleCall(Session session, Request request) async { - final user = userProperty[request]; + final tenant = tenantProperty[request]; - // Fetch user-specific data - final data = await fetchDashboardData(session, user.id); + // Fetch tenant-specific data + final data = await session.db.find( + where: (p) => p.tenantId.equals(tenant), + ); return Response.ok( body: Body.fromString( - jsonEncode(data), + jsonEncode(data.map((p) => p.toJson()).toList()), mimeType: MimeType.json, ), ); @@ -912,9 +885,9 @@ class UserDashboardRoute extends Route { You can use multiple context properties for different types of data: ```dart -final userProperty = ContextProperty(); final requestIdProperty = ContextProperty(); final tenantProperty = ContextProperty(); +final apiVersionProperty = ContextProperty(); Handler requestContextMiddleware(Handler innerHandler) { return (Request request) async { @@ -923,25 +896,32 @@ Handler requestContextMiddleware(Handler innerHandler) { requestIdProperty[request] = requestId; // Extract tenant from subdomain or header - final tenant = extractTenant(request); - tenantProperty[request] = tenant; + final host = request.headers.host; + if (host != null) { + final tenant = host.host.split('.').first; + tenantProperty[request] = tenant; + } + + // Extract API version from header + final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; + apiVersionProperty[request] = apiVersion; return await innerHandler(request); }; } // Later in your route -class TenantDataRoute extends Route { +class DataRoute extends Route { @override Future handleCall(Session session, Request request) async { - final user = userProperty[request]; final requestId = requestIdProperty[request]; final tenant = tenantProperty[request]; + final apiVersion = apiVersionProperty[request]; - session.log('Request $requestId for tenant $tenant by user ${user.id}'); + session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); // Fetch tenant-specific data - final data = await fetchTenantData(session, tenant, user.id); + final data = await fetchTenantData(session, tenant); return Response.ok( body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), @@ -952,7 +932,7 @@ class TenantDataRoute extends Route { :::tip Best Practices - Define `ContextProperty` instances as top-level constants or static fields -- Use descriptive names for your properties (e.g., `userProperty`, not just `user`) +- Use descriptive names for your properties (e.g., `requestIdProperty`, not just `requestId`) - Use `getOrNull()` when the value might not be set - Set properties in middleware, not in routes - Use specific types for better type safety From 9177dc890cb211529bef863b95737c544e8158f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:37:32 +0100 Subject: [PATCH 13/39] docs: Replace authentication middleware with API key validation example - Changed from user authentication to API key validation - Added info box clarifying to use Serverpod's built-in auth for users - Keeps middleware examples focused on web-specific validations --- docs/06-concepts/18-webserver.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 3ecfb099..9769af0c 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -605,27 +605,26 @@ Handler loggingMiddleware(Handler innerHandler) { } ``` -### Authentication middleware +### API key validation middleware -A common use case is adding authentication to protected routes: +A common use case is validating API keys for protected routes: ```dart -Handler authMiddleware(Handler innerHandler) { +Handler apiKeyMiddleware(Handler innerHandler) { return (Request request) async { - // Check for authentication token - final authHeader = request.headers.authorization; + // Check for API key in header + final apiKey = request.headers['X-API-Key']?.firstOrNull; - if (authHeader == null) { + if (apiKey == null) { return Response.unauthorized( - body: Body.fromString('Authentication required'), + body: Body.fromString('API key required'), ); } - // Verify token (simplified example) - final token = authHeader.headerValue; - if (!await verifyToken(token)) { + // Verify API key + if (!await isValidApiKey(apiKey)) { return Response.forbidden( - body: Body.fromString('Invalid token'), + body: Body.fromString('Invalid API key'), ); } @@ -635,9 +634,13 @@ Handler authMiddleware(Handler innerHandler) { } // Apply to protected routes -pod.webServer.addMiddleware(authMiddleware, '/admin'); +pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); ``` +:::info +For user authentication, use Serverpod's built-in authentication system which integrates with the `Session` object. The middleware examples here are for additional web-specific validations like API keys, rate limiting, or request validation. +::: + ### CORS middleware Enable Cross-Origin Resource Sharing for your API: From 4f8906999980353049af72d65940c65102f26179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:48:38 +0100 Subject: [PATCH 14/39] docs: Add critical clarifications for error handling and Session access - Add info box explaining error handling is omitted for brevity - Document automatic 500/400 error responses for uncaught exceptions - Clarify Session access pattern difference between Route.handleCall() and modular routes - Explain that injectIn handlers use request.session while handleCall receives Session parameter --- docs/06-concepts/18-webserver.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 9769af0c..d1d64973 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -203,6 +203,19 @@ class ApiRoute extends Route { pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` +:::info Error Handling +The examples in this documentation omit error handling for brevity. In production code, you should handle potential exceptions: +- `jsonDecode()` can throw `FormatException` for invalid JSON +- Database operations can throw exceptions +- File operations can fail + +If an exception escapes your handler, Serverpod will automatically return: +- **500 Internal Server Error** for general exceptions +- **400 Bad Request** for malformed headers or invalid requests + +For better user experience, catch and handle exceptions explicitly to return appropriate error responses. +::: + ### HTTP methods Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: @@ -354,6 +367,19 @@ For complex applications, you can create modular route classes that register mul When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for the specified path. By overriding `injectIn`, you can register multiple routes instead of just one. +:::info Session Access in Modular Routes +When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use `request.session`: + +```dart +Future _handler(Request request) async { + final session = request.session; // Extract Session from Request + // ... use session +} +``` + +This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. +::: + #### Creating a CRUD module Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: From a6a29fc42324f38b64c44cc6de1ad7d37056c0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 11:49:37 +0100 Subject: [PATCH 15/39] docs: Add error handling middleware section - Add comprehensive error handling middleware example - Handles FormatException for invalid JSON - Logs errors with stack traces - Returns appropriate HTTP status codes (400/500) - Update earlier error handling info box to recommend middleware approach - Emphasizes centralized error handling over per-route try-catch blocks --- docs/06-concepts/18-webserver.md | 50 +++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index d1d64973..7f53f01a 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -204,16 +204,11 @@ pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` :::info Error Handling -The examples in this documentation omit error handling for brevity. In production code, you should handle potential exceptions: -- `jsonDecode()` can throw `FormatException` for invalid JSON -- Database operations can throw exceptions -- File operations can fail +The examples in this documentation omit error handling for brevity. In production code, you should handle potential exceptions from `jsonDecode()`, database operations, and file operations. -If an exception escapes your handler, Serverpod will automatically return: -- **500 Internal Server Error** for general exceptions -- **400 Bad Request** for malformed headers or invalid requests +**Recommended approach**: Use error handling middleware (see the Middleware section below) to catch exceptions globally rather than adding try-catch blocks to every route handler. This centralizes error handling and ensures consistent error responses across your API. -For better user experience, catch and handle exceptions explicitly to return appropriate error responses. +If an exception escapes all handlers and middleware, Serverpod will automatically return a **500 Internal Server Error** response. ::: ### HTTP methods @@ -700,6 +695,45 @@ Handler corsMiddleware(Handler innerHandler) { pod.webServer.addMiddleware(corsMiddleware, '/api'); ``` +### Error handling middleware + +A global error handling middleware can catch exceptions from routes and return appropriate error responses: + +```dart +Handler errorHandlingMiddleware(Handler innerHandler) { + return (Request request) async { + try { + return await innerHandler(request); + } on FormatException catch (e) { + // Handle JSON parsing errors + return Response.badRequest( + body: Body.fromString( + jsonEncode({'error': 'Invalid request format', 'message': e.message}), + mimeType: MimeType.json, + ), + ); + } catch (e, stackTrace) { + // Log the error + print('Error handling ${request.method} ${request.url.path}: $e'); + print(stackTrace); + + // Return a generic error response + return Response.internalServerError( + body: Body.fromString( + jsonEncode({'error': 'Internal server error'}), + mimeType: MimeType.json, + ), + ); + } + }; +} + +// Apply to all routes +pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); +``` + +With error handling middleware in place, your route handlers can focus on business logic without extensive try-catch blocks. Uncaught exceptions will be caught and converted to appropriate HTTP responses. + ### Middleware execution order Middleware is applied based on path hierarchy, with more specific paths taking precedence. Within the same path, middleware executes in the order it was registered: From 9ca1c25f528584d46044c15bbb31653e8546b73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:03:21 +0100 Subject: [PATCH 16/39] docs: Add connecting prose to improve documentation flow - Add context for special widgets and static files - Introduce Advanced routing section with overview of patterns covered - Explain the value of custom Route classes vs WidgetRoute - Add prose explaining modular routes and when to use them - Introduce middleware concept with explanation of cross-cutting concerns - Add context for ContextProperty and why immutability requires it - Explain typed headers benefits vs string-based parsing - Introduce custom typed headers with step-by-step overview These additions help readers understand not just *how* to use features, but *why* they exist and *when* to use them. --- docs/06-concepts/18-webserver.md | 43 ++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 7f53f01a..5eb235ed 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -55,13 +55,16 @@ In the future, we plan to add a widget library to Serverpod with widgets corresp ## Special widgets and routes -Serverpod comes with a few useful special widgets and routes you can use out of the box. When returning these special widget types, Serverpod's web server will automatically set the correct HTTP status codes and content types. +While `WidgetRoute` is great for custom HTML pages, Serverpod provides several built-in widgets and routes for common use cases. These special types automatically handle HTTP status codes and content types, so you don't need to configure them manually. -- `ListWidget` concatenates a list of other widgets into a single widget. -- `JsonWidget` renders a JSON document from a serializable structure of maps, lists, and basic values. -- `RedirectWidget` creates a redirect to another URL. +**Built-in widgets:** +- `ListWidget` - Concatenates multiple widgets into a single response +- `JsonWidget` - Renders JSON documents from serializable data structures +- `RedirectWidget` - Creates HTTP redirects to other URLs -To serve a static directory, use the `StaticRoute.directory()` method. Serverpod will set the correct content types for most file types automatically. +**Serving static files:** + +Static assets like images, CSS, and JavaScript files are essential for web applications. The `StaticRoute.directory()` method makes it easy to serve entire directories with automatic content-type detection for common file formats. ### Static file cache-busting @@ -164,9 +167,13 @@ The web server passes a `Session` object to the `WidgetRoute` class' `build` met ## Advanced routing +The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern web applications often need more flexibility. Whether you're building REST APIs, handling file uploads, or creating webhooks for third-party integrations, Serverpod's routing system provides the tools you need. + +This section explores advanced routing patterns including custom route handlers, HTTP method handling, path parameters, wildcards, and modular route organization. These patterns give you fine-grained control over how your web server processes requests and generates responses. + ### Custom Route classes -While `WidgetRoute` is convenient for rendering HTML pages, you can also create custom `Route` subclasses for more control over the response. This is useful for REST APIs, file downloads, or custom response handling. +While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base class gives you complete control over request handling. By extending `Route` and implementing `handleCall()`, you can build REST APIs, serve files, or handle any custom HTTP interaction. This is ideal when you need to work directly with request bodies, headers, and response formats. ```dart class ApiRoute extends Route { @@ -358,9 +365,9 @@ pod.webServer.fallbackRoute = NotFoundRoute(); ### Modular routes with injectIn -For complex applications, you can create modular route classes that register multiple sub-routes by overriding the `injectIn` method. This allows you to organize related routes into reusable modules. +As your web server grows, managing dozens of individual route registrations can become unwieldy. Modular routes solve this by letting you group related endpoints into reusable modules. For example, you might create a `UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. -When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for the specified path. By overriding `injectIn`, you can register multiple routes instead of just one. +The key to modular routes is the `injectIn()` method. When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for that path. By overriding `injectIn()`, you can register multiple handler functions instead of implementing a single `handleCall()` method. This pattern is perfect for REST resources, API modules, or any group of related endpoints. :::info Session Access in Modular Routes When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use `request.session`: @@ -591,7 +598,9 @@ When overriding `injectIn`, you typically don't need to implement `handleCall` s ## Middleware -Middleware allows you to add cross-cutting functionality to your web server, such as authentication, logging, CORS handling, or request validation. Middleware functions wrap your route handlers, executing code before and after the request is processed. +Routes handle the core logic of your application, but many concerns cut across multiple routes: logging every request, validating API keys, handling CORS headers, or catching errors. Rather than duplicating this code in each route, middleware lets you apply it globally or to specific path prefixes. + +Middleware functions are wrappers that sit between the incoming request and your route handler. They can inspect or modify requests before they reach your routes, and transform responses before they're sent back to the client. This makes middleware perfect for authentication, logging, error handling, and any other cross-cutting concern in your web server. ### Adding middleware @@ -755,7 +764,9 @@ For a request to `/api/users`, the execution order is: ### Request-scoped data with ContextProperty -`ContextProperty` provides a type-safe way to attach data to a `Request` object that can be accessed by downstream middleware and route handlers. This is the recommended pattern for passing computed data through your request pipeline. +Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a subdomain, or a logging middleware might generate a request ID for tracing. Since `Request` objects are immutable, you can't just add properties to them. This is where `ContextProperty` comes in. + +`ContextProperty` provides a type-safe way to attach data to a `Request` object without modifying it. Think of it as a side channel for request-scoped data that middleware can write to and routes can read from. The data is automatically cleaned up when the request completes, making it perfect for per-request state. :::info Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard Session. @@ -763,7 +774,7 @@ Note that Serverpod's `Route.handleCall()` already receives a `Session` paramete #### Why use ContextProperty? -Instead of modifying the `Request` object directly (which you can't do since it's immutable), `ContextProperty` allows you to associate additional data with a request. Common use cases include: +Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that can be accessed by all downstream middleware and handlers. Common use cases include: - **Request ID tracking** - Add correlation IDs for logging and tracing - **Tenant identification** - Multi-tenant application context from subdomains/headers @@ -1015,7 +1026,9 @@ This logs all requests with method, path, status code, and response time. ## Typed headers -Serverpod's web server (via Relic) provides a type-safe header system that goes beyond simple string-based HTTP headers. Instead of working with raw strings, you can access and set HTTP headers using strongly-typed Dart objects with automatic parsing and validation. +HTTP headers are traditionally accessed as strings, which means you need to manually parse values, handle edge cases, and validate formats. Serverpod's web server (via Relic) provides a better approach: typed headers that automatically parse header values into strongly-typed Dart objects. + +For example, instead of parsing `Authorization: Bearer abc123` as a string and extracting the token yourself, you can access `request.headers.authorization` to get a `BearerAuthorizationHeader` object with the token already parsed and validated. This eliminates boilerplate code, catches errors early, and makes your code more maintainable. ### Reading typed headers @@ -1340,7 +1353,11 @@ class LoginRoute extends Route { ### Creating custom typed headers -You can create your own typed headers by defining a header class and a `HeaderAccessor`. Here's a simple example for a custom `X-API-Version` header: +While Relic provides typed headers for all standard HTTP headers, your application may use custom headers for API versioning, feature flags, or application-specific metadata. Rather than falling back to string-based access for these custom headers, you can create your own typed headers using the same pattern Relic uses internally. + +Creating a custom typed header involves three steps: defining the header class with parsing logic, creating a codec for serialization, and setting up a `HeaderAccessor` for type-safe access. Once configured, your custom headers work just like the built-in ones, with automatic parsing, validation, and convenient property-style access. + +Here's a complete example for a custom `X-API-Version` header: ```dart // Define your typed header class From c6c9451c53631ae316b008bc4471db8eafaa53ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:05:17 +0100 Subject: [PATCH 17/39] docs: Rewrap webserver section --- docs/06-concepts/18-webserver.md | 1714 +++++++++++------------------- package-lock.json | 44 +- 2 files changed, 675 insertions(+), 1083 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 5eb235ed..784cab0b 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -1,61 +1,77 @@ # Web server -In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it very easy to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with 3rd party services. +In addition to the application server, Serverpod comes with a built-in web +server. The web server allows you to access your database and business layer the +same way you would from a method call from an app. This makes it very easy to +share data for applications that need both an app and traditional web pages. You +can also use the web server to create webhooks or generate custom REST APIs to +communicate with 3rd party services. :::caution -Serverpod's web server is still experimental, and the APIs may change in the future. This documentation should give you some hints on getting started, but we plan to add more extensive documentation as the web server matures. +Serverpod's web server is still experimental, and the APIs may change in the +future. This documentation should give you some hints on getting started, but we +plan to add more extensive documentation as the web server matures. ::: -When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders a web page using templates, while other `WebWidget` types can render JSON or other custom responses. +When you create a new Serverpod project, it sets up a web server by default. +When working with the web server, there are two main classes to understand; +`WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point +for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders +a web page using templates, while other `WebWidget` types can render JSON or +other custom responses. ## Creating new routes and widgets -To add new pages to your web server, you add new routes. Typically, you do this in your server.dart file before you start the Serverpod. By default, Serverpod comes with a `RootRoute` and a static directory. +To add new pages to your web server, you add new routes. Typically, you do this +in your server.dart file before you start the Serverpod. By default, Serverpod +comes with a `RootRoute` and a static directory. -When receiving a web request, Serverpod will search and match the routes in the order they were added. You can end a route's path with an asterisk (`*`) to match all paths with the same beginning. +When receiving a web request, Serverpod will search and match the routes in the +order they were added. You can end a route's path with an asterisk (`*`) to +match all paths with the same beginning. -```dart -// Add a single page. -pod.webServer.addRoute(MyRoute(), '/my/page/address'); +```dart // Add a single page. pod.webServer.addRoute(MyRoute(), +'/my/page/address'); -// Match all paths that start with /item/ -pod.webServer.addRoute(AnotherRoute(), '/item/*'); -``` +// Match all paths that start with /item/ pod.webServer.addRoute(AnotherRoute(), +'/item/*'); ``` -Typically, you want to create custom routes for your pages. Do this by overriding the WidgetRoute class and implementing the build method. +Typically, you want to create custom routes for your pages. Do this by +overriding the WidgetRoute class and implementing the build method. -```dart -class MyRoute extends WidgetRoute { - @override - Future build(Session session, Request request) async { - return MyPageWidget(title: 'Home page'); - } -} -``` +```dart class MyRoute extends WidgetRoute { @override Future +build(Session session, Request request) async { return MyPageWidget(title: 'Home +page'); } } ``` -Your route's build method returns a `WebWidget`. The `TemplateWidget` consists of an HTML template file and a corresponding Dart class. Create a new custom widget by extending the `TemplateWidget` class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your widget class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest widgets, similarly to how widgets work in Flutter. +Your route's build method returns a `WebWidget`. The `TemplateWidget` consists +of an HTML template file and a corresponding Dart class. Create a new custom +widget by extending the `TemplateWidget` class. Then add a corresponding HTML +template and place it in the `web/templates` directory. The HTML file uses the +[Mustache](https://mustache.github.io/) template language. You set your template +parameters by updating the `values` field of your widget class. The values are +converted to `String` objects before being passed to the template. This makes it +possible to nest widgets, similarly to how widgets work in Flutter. -```dart -class MyPageWidget extends TemplateWidget { - MyPageWidget({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` +```dart class MyPageWidget extends TemplateWidget { MyPageWidget({required +String title}) : super(name: 'my_page') { values = { 'title': title, }; } } ``` :::info -In the future, we plan to add a widget library to Serverpod with widgets corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side widgets with similar code used within Flutter. +In the future, we plan to add a widget library to Serverpod with widgets +corresponding to the standard widgets used by Flutter, such as Column, Row, +Padding, Container, etc. This would make it possible to render server-side +widgets with similar code used within Flutter. ::: ## Special widgets and routes -While `WidgetRoute` is great for custom HTML pages, Serverpod provides several built-in widgets and routes for common use cases. These special types automatically handle HTTP status codes and content types, so you don't need to configure them manually. +While `WidgetRoute` is great for custom HTML pages, Serverpod provides several +built-in widgets and routes for common use cases. These special types +automatically handle HTTP status codes and content types, so you don't need to +configure them manually. **Built-in widgets:** - `ListWidget` - Concatenates multiple widgets into a single response @@ -64,66 +80,53 @@ While `WidgetRoute` is great for custom HTML pages, Serverpod provides several b **Serving static files:** -Static assets like images, CSS, and JavaScript files are essential for web applications. The `StaticRoute.directory()` method makes it easy to serve entire directories with automatic content-type detection for common file formats. +Static assets like images, CSS, and JavaScript files are essential for web +applications. The `StaticRoute.directory()` method makes it easy to serve entire +directories with automatic content-type detection for common file formats. ### Static file cache-busting -When deploying static assets, browsers and CDNs (like CloudFront) cache files aggressively for performance. This means updated files may not be served to users unless you implement a cache-busting strategy. +When deploying static assets, browsers and CDNs (like CloudFront) cache files +aggressively for performance. This means updated files may not be served to +users unless you implement a cache-busting strategy. -Serverpod provides `CacheBustingConfig` to automatically version your static files: +Serverpod provides `CacheBustingConfig` to automatically version your static +files: -```dart -final staticDir = Directory('web/static'); +```dart final staticDir = Directory('web/static'); -final cacheBustingConfig = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticDir, - separator: '@', // or use custom separator like '___' +final cacheBustingConfig = CacheBustingConfig( mountPrefix: '/static', +fileSystemRoot: staticDir, separator: '@', // or use custom separator like '___' ); -pod.webServer.addRoute( - StaticRoute.directory( - staticDir, - cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), - ), - '/static/**', -); -``` +pod.webServer.addRoute( StaticRoute.directory( staticDir, cacheBustingConfig: +cacheBustingConfig, cacheControlFactory: StaticRoute.publicImmutable(maxAge: +31536000), ), '/static/**', ); ``` **Generating versioned URLs:** Use the `assetPath()` method to generate cache-busted URLs for your assets: -```dart -// In your route handler -final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); -// Returns: /static/logo@.png +```dart // In your route handler final imageUrl = await +cacheBustingConfig.assetPath('/static/logo.png'); // Returns: +/static/logo@.png -// Pass to your template -return MyPageWidget(logoUrl: imageUrl); -``` +// Pass to your template return MyPageWidget(logoUrl: imageUrl); ``` The cache-busting system: - Automatically generates content-based hashes for asset versioning - Allows custom separators (default `@`, but you can use `___` or any other) - Preserves file extensions -- Works transparently - requesting `/static/logo@abc123.png` serves `/static/logo.png` +- Works transparently - requesting `/static/logo@abc123.png` serves + `/static/logo.png` **Combining with cache control:** For optimal performance, combine cache-busting with aggressive caching: -```dart -pod.webServer.addRoute( - StaticRoute.directory( - staticDir, - cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year - ), - '/static/**', -); -``` +```dart pod.webServer.addRoute( StaticRoute.directory( staticDir, +cacheBustingConfig: cacheBustingConfig, cacheControlFactory: +StaticRoute.publicImmutable(maxAge: 31536000), // 1 year ), '/static/**', ); ``` This approach ensures: - Browsers cache files for a long time (better performance) @@ -132,95 +135,92 @@ This approach ensures: ### Conditional requests (ETags and Last-Modified) -`StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file content when unchanged: +`StaticRoute` automatically supports HTTP conditional requests through Relic's +`StaticHandler`. This provides efficient caching without transferring file +content when unchanged: **Supported features:** - **ETag headers** - Content-based fingerprinting for cache validation - **Last-Modified headers** - Timestamp-based cache validation -- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if unchanged -- **If-Modified-Since** - Client sends timestamp, server returns 304 if not modified +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if + unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not + modified These work automatically without configuration: -```dart -// Client first request: -// GET /static/logo.png -// Response: 200 OK -// ETag: "abc123" -// Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT -// Content-Length: 12345 -// [file content] - -// Client subsequent request: -// GET /static/logo.png -// If-None-Match: "abc123" -// Response: 304 Not Modified -// ETag: "abc123" -// [no body - saves bandwidth] +```dart // Client first request: // GET /static/logo.png // Response: 200 OK // +ETag: "abc123" // Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT // +Content-Length: 12345 // [file content] + +// Client subsequent request: // GET /static/logo.png // If-None-Match: "abc123" +// Response: 304 Not Modified // ETag: "abc123" // [no body - saves bandwidth] ``` -When combined with cache-busting, conditional requests provide a fallback validation mechanism even for cached assets, ensuring efficient delivery while maintaining correctness. +When combined with cache-busting, conditional requests provide a fallback +validation mechanism even for cached assets, ensuring efficient delivery while +maintaining correctness. ## Database access and logging -The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. +The web server passes a `Session` object to the `WidgetRoute` class' `build` +method. This gives you access to all the features you typically get from a +standard method call to an endpoint. Use the database, logging, or caching the +same way you would in a method call. ## Advanced routing -The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern web applications often need more flexibility. Whether you're building REST APIs, handling file uploads, or creating webhooks for third-party integrations, Serverpod's routing system provides the tools you need. +The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern +web applications often need more flexibility. Whether you're building REST APIs, +handling file uploads, or creating webhooks for third-party integrations, +Serverpod's routing system provides the tools you need. -This section explores advanced routing patterns including custom route handlers, HTTP method handling, path parameters, wildcards, and modular route organization. These patterns give you fine-grained control over how your web server processes requests and generates responses. +This section explores advanced routing patterns including custom route handlers, +HTTP method handling, path parameters, wildcards, and modular route +organization. These patterns give you fine-grained control over how your web +server processes requests and generates responses. ### Custom Route classes -While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base class gives you complete control over request handling. By extending `Route` and implementing `handleCall()`, you can build REST APIs, serve files, or handle any custom HTTP interaction. This is ideal when you need to work directly with request bodies, headers, and response formats. +While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base +class gives you complete control over request handling. By extending `Route` and +implementing `handleCall()`, you can build REST APIs, serve files, or handle any +custom HTTP interaction. This is ideal when you need to work directly with +request bodies, headers, and response formats. -```dart -class ApiRoute extends Route { - ApiRoute() : super(methods: {Method.get, Method.post}); +```dart class ApiRoute extends Route { ApiRoute() : super(methods: {Method.get, +Method.post}); - @override - Future handleCall(Session session, Request request) async { - // Access request method - if (request.method == Method.post) { - // Read request body - final body = await request.readAsString(); - final data = jsonDecode(body); + @override Future handleCall(Session session, Request request) async { + // Access request method if (request.method == Method.post) { // Read request + body final body = await request.readAsString(); final data = jsonDecode(body); - // Process and return JSON response - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'success', 'data': data}), - mimeType: MimeType.json, - ), - ); - } + // Process and return JSON response return Response.ok( body: + Body.fromString( jsonEncode({'status': 'success', 'data': data}), + mimeType: MimeType.json, ), ); } - // Return data for GET requests - return Response.ok( - body: Body.fromString( - jsonEncode({'message': 'Hello from API'}), - mimeType: MimeType.json, - ), - ); - } -} - -// Register the route -pod.webServer.addRoute(ApiRoute(), '/api/data'); -``` + // Return data for GET requests return Response.ok( body: Body.fromString( + jsonEncode({'message': 'Hello from API'}), mimeType: MimeType.json, ), ); } + } -:::info Error Handling -The examples in this documentation omit error handling for brevity. In production code, you should handle potential exceptions from `jsonDecode()`, database operations, and file operations. +// Register the route pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` -**Recommended approach**: Use error handling middleware (see the Middleware section below) to catch exceptions globally rather than adding try-catch blocks to every route handler. This centralizes error handling and ensures consistent error responses across your API. +:::info Error Handling The examples in this documentation omit error handling +for brevity. In production code, you should handle potential exceptions from +`jsonDecode()`, database operations, and file operations. -If an exception escapes all handlers and middleware, Serverpod will automatically return a **500 Internal Server Error** response. -::: +**Recommended approach**: Use error handling middleware (see the Middleware +section below) to catch exceptions globally rather than adding try-catch blocks +to every route handler. This centralizes error handling and ensures consistent +error responses across your API. + +If an exception escapes all handlers and middleware, Serverpod will +automatically return a **500 Internal Server Error** response. ::: ### HTTP methods -Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: +Routes can specify which HTTP methods they respond to using the `methods` +parameter. The available methods are: - `Method.get` - Retrieve data - `Method.post` - Create new resources @@ -230,314 +230,196 @@ Routes can specify which HTTP methods they respond to using the `methods` parame - `Method.head` - Same as GET but without response body - `Method.options` - Query supported methods (used for CORS) -```dart -class UserRoute extends Route { - UserRoute() : super( - methods: {Method.get, Method.post, Method.delete}, - ); - - @override - Future handleCall(Session session, Request request) async { - switch (request.method) { - case Method.get: - return await _getUser(request); - case Method.post: - return await _createUser(request); - case Method.delete: - return await _deleteUser(request); - default: - return Response.methodNotAllowed(); - } - } -} -``` +```dart class UserRoute extends Route { UserRoute() : super( methods: +{Method.get, Method.post, Method.delete}, ); + + @override Future handleCall(Session session, Request request) async { + switch (request.method) { case Method.get: return await _getUser(request); + case Method.post: return await _createUser(request); case Method.delete: + return await _deleteUser(request); default: return + Response.methodNotAllowed(); } } } ``` ### Path parameters -Routes support named path parameters using the `:paramName` syntax. These are automatically extracted and made available through the `Request` object: +Routes support named path parameters using the `:paramName` syntax. These are +automatically extracted and made available through the `Request` object: -```dart -class UserRoute extends Route { - UserRoute() : super(methods: {Method.get}); +```dart class UserRoute extends Route { UserRoute() : super(methods: +{Method.get}); - @override - void injectIn(RelicRouter router) { - // Define route with path parameter - router.get('/:id', asHandler); - } + @override void injectIn(RelicRouter router) { // Define route with path + parameter router.get('/:id', asHandler); } - @override - Future handleCall(Session session, Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; + @override Future handleCall(Session session, Request request) async { + // Extract path parameter using symbol final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } + if (id == null) { return Response.badRequest( body: Body.fromString('Missing + user ID'), ); } - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } + final userId = int.tryParse(id); if (userId == null) { return + Response.badRequest( body: Body.fromString('Invalid user ID'), ); } final user = await User.db.findById(session, userId); - if (user == null) { - return Response.notFound(); - } + if (user == null) { return Response.notFound(); } - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } -} + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), + mimeType: MimeType.json, ), ); } } // Register at /api/users - will match /api/users/123 -pod.webServer.addRoute(UserRoute(), '/api/users'); -``` +pod.webServer.addRoute(UserRoute(), '/api/users'); ``` You can use multiple path parameters in a single route: -```dart -router.get('/:userId/posts/:postId', handler); -// Matches: /123/posts/456 -// request.pathParameters[#userId] => '123' -// request.pathParameters[#postId] => '456' -``` +```dart router.get('/:userId/posts/:postId', handler); // Matches: +/123/posts/456 // request.pathParameters[#userId] => '123' // +request.pathParameters[#postId] => '456' ``` ### Wildcards Routes also support wildcard matching for catching all paths: -```dart -// Single-level wildcard - matches /item/foo but not /item/foo/bar +```dart // Single-level wildcard - matches /item/foo but not /item/foo/bar pod.webServer.addRoute(ItemRoute(), '/item/*'); // Tail-match wildcard - matches /item/foo and /item/foo/bar/baz -pod.webServer.addRoute(ItemRoute(), '/item/**'); -``` +pod.webServer.addRoute(ItemRoute(), '/item/**'); ``` -:::info Performance Guarantee -The `/**` wildcard is a **tail-match** pattern and can only appear at the end of a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. This design ensures O(h) route lookup performance, where h is the path length, without backtracking. This keeps routing fast and predictable, even with many routes. -::: +:::info Performance Guarantee The `/**` wildcard is a **tail-match** pattern and +can only appear at the end of a route path (e.g., `/static/**`). Patterns like +`/a/**/b` are not supported. This design ensures O(h) route lookup performance, +where h is the path length, without backtracking. This keeps routing fast and +predictable, even with many routes. ::: Access the matched path information through the `Request` object: -```dart -@override -Future handleCall(Session session, Request request) async { - // Get the remaining path after the route prefix - final remainingPath = request.url.path; +```dart @override Future handleCall(Session session, Request request) +async { // Get the remaining path after the route prefix final remainingPath = +request.url.path; - // Access query parameters - final query = request.url.queryParameters['query']; + // Access query parameters final query = request.url.queryParameters['query']; - return Response.ok( - body: Body.fromString('Path: $remainingPath, Query: $query'), - ); -} -``` + return Response.ok( body: Body.fromString('Path: $remainingPath, Query: + $query'), ); } ``` ### Fallback routes You can set a fallback route that handles requests when no other route matches: -```dart -class NotFoundRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - return Response.notFound( - body: Body.fromString('Page not found: ${request.url.path}'), - ); - } -} - -// Set as fallback -pod.webServer.fallbackRoute = NotFoundRoute(); -``` +```dart class NotFoundRoute extends Route { @override Future +handleCall(Session session, Request request) async { return Response.notFound( +body: Body.fromString('Page not found: ${request.url.path}'), ); } } + +// Set as fallback pod.webServer.fallbackRoute = NotFoundRoute(); ``` ### Modular routes with injectIn -As your web server grows, managing dozens of individual route registrations can become unwieldy. Modular routes solve this by letting you group related endpoints into reusable modules. For example, you might create a `UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. +As your web server grows, managing dozens of individual route registrations can +become unwieldy. Modular routes solve this by letting you group related +endpoints into reusable modules. For example, you might create a +`UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST +/users`, `PUT /users/:id`, etc.) in a single cohesive unit. -The key to modular routes is the `injectIn()` method. When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for that path. By overriding `injectIn()`, you can register multiple handler functions instead of implementing a single `handleCall()` method. This pattern is perfect for REST resources, API modules, or any group of related endpoints. +The key to modular routes is the `injectIn()` method. When you call +`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` +on a router group for that path. By overriding `injectIn()`, you can register +multiple handler functions instead of implementing a single `handleCall()` +method. This pattern is perfect for REST resources, API modules, or any group of +related endpoints. -:::info Session Access in Modular Routes -When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use `request.session`: +:::info Session Access in Modular Routes When using `injectIn()` with handler +functions (`router.get('/', _handler)`), your handlers receive only a `Request` +parameter. To access the `Session`, use `request.session`: -```dart -Future _handler(Request request) async { - final session = request.session; // Extract Session from Request - // ... use session -} -``` +```dart Future _handler(Request request) async { final session = +request.session; // Extract Session from Request // ... use session } ``` -This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. -::: +This differs from `Route.handleCall()` which receives both Session and Request +as explicit parameters. The modular route pattern uses Relic's router directly, +which only provides Request to handlers. ::: #### Creating a CRUD module -Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: - -```dart -class UserCrudModule extends Route { - @override - void injectIn(RelicRouter router) { - // Register multiple routes with path parameters - router - ..get('/', _list) - ..get('/:id', _get) - ..post('/', _create) - ..put('/:id', _update) - ..delete('/:id', _delete); - } - - // Handler methods - Future _list(Request request) async { - final session = request.session; - final users = await User.db.find(session); - - return Response.ok( - body: Body.fromString( - jsonEncode(users.map((u) => u.toJson()).toList()), - mimeType: MimeType.json, - ), - ); - } - - Future _get(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } +Here's an example of a modular CRUD route that registers multiple endpoints with +path parameters: + +```dart class UserCrudModule extends Route { @override void injectIn(RelicRouter +router) { // Register multiple routes with path parameters router ..get('/', +_list) ..get('/:id', _get) ..post('/', _create) ..put('/:id', _update) +..delete('/:id', _delete); } + + // Handler methods Future _list(Request request) async { final session + = request.session; final users = await User.db.find(session); - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } + return Response.ok( body: Body.fromString( jsonEncode(users.map((u) => + u.toJson()).toList()), mimeType: MimeType.json, ), ); } + + Future _get(Request request) async { // Extract path parameter using + symbol final id = request.pathParameters[#id]; if (id == null) { return + Response.badRequest( body: Body.fromString('Missing user ID'), ); } - final session = request.session; - final user = await User.db.findById(session, userId); + final userId = int.tryParse(id); if (userId == null) { return + Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } + final session = request.session; final user = await + User.db.findById(session, userId); - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _create(Request request) async { - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; + if (user == null) { return Response.notFound( body: Body.fromString('User + not found'), ); } - final user = User(name: data['name'], email: data['email']); - await User.db.insertRow(session, user); - - return Response.created( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _update(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), + mimeType: MimeType.json, ), ); } + + Future _create(Request request) async { final body = await + request.readAsString(); final data = jsonDecode(body); final session = + request.session; - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } + final user = User(name: data['name'], email: data['email']); await + User.db.insertRow(session, user); + + return Response.created( body: Body.fromString( jsonEncode(user.toJson()), + mimeType: MimeType.json, ), ); } + + Future _update(Request request) async { // Extract path parameter + using symbol final id = request.pathParameters[#id]; if (id == null) { return + Response.badRequest( body: Body.fromString('Missing user ID'), ); } - final body = await request.readAsString(); - final data = jsonDecode(body); + final userId = int.tryParse(id); if (userId == null) { return + Response.badRequest( body: Body.fromString('Invalid user ID'), ); } + + final body = await request.readAsString(); final data = jsonDecode(body); final session = request.session; - final user = await User.db.findById(session, userId); - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } + final user = await User.db.findById(session, userId); if (user == null) { + return Response.notFound( body: Body.fromString('User not found'), ); } - user.name = data['name'] ?? user.name; - user.email = data['email'] ?? user.email; - await User.db.updateRow(session, user); - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _delete(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } + user.name = data['name'] ?? user.name; user.email = data['email'] ?? + user.email; await User.db.updateRow(session, user); - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), + mimeType: MimeType.json, ), ); } + + Future _delete(Request request) async { // Extract path parameter + using symbol final id = request.pathParameters[#id]; if (id == null) { return + Response.badRequest( body: Body.fromString('Missing user ID'), ); } - final session = request.session; - final deleted = await User.db.deleteRow(session, userId); + final userId = int.tryParse(id); if (userId == null) { return + Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - if (!deleted) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } + final session = request.session; final deleted = await + User.db.deleteRow(session, userId); + + if (!deleted) { return Response.notFound( body: Body.fromString('User not + found'), ); } - return Response.noContent(); - } + return Response.noContent(); } - // Required by Route but not used since we override injectIn - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} + // Required by Route but not used since we override injectIn @override + Future handleCall(Session session, Request request) async { throw + UnimplementedError('This route uses injectIn'); } } // Register the entire CRUD module under /api/users -pod.webServer.addRoute(UserCrudModule(), '/api/users'); -``` +pod.webServer.addRoute(UserCrudModule(), '/api/users'); ``` This creates the following RESTful endpoints: - `GET /api/users` - List all users @@ -546,212 +428,160 @@ This creates the following RESTful endpoints: - `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) - `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) -:::tip -Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. Always validate and parse these values since they come from user input as strings. -::: +:::tip Path parameters are accessed using symbols: +`request.pathParameters[#paramName]`. Always validate and parse these values +since they come from user input as strings. ::: #### Composing multiple modules You can create a parent module that composes multiple sub-modules: -```dart -class ApiModule extends Route { - @override - void injectIn(RelicRouter router) { - // Inject sub-modules at different paths - router.group('/users').inject(UserCrudModule()); - router.group('/posts').inject(PostCrudModule()); - router.group('/comments').inject(CommentCrudModule()); - - // Add module-level routes - router.get('/health', _healthCheck); - } - - Future _healthCheck(Request request) async { - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), - mimeType: MimeType.json, - ), - ); - } - - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire API module -pod.webServer.addRoute(ApiModule(), '/api'); +```dart class ApiModule extends Route { @override void injectIn(RelicRouter +router) { // Inject sub-modules at different paths +router.group('/users').inject(UserCrudModule()); +router.group('/posts').inject(PostCrudModule()); +router.group('/comments').inject(CommentCrudModule()); + + // Add module-level routes router.get('/health', _healthCheck); } + + Future _healthCheck(Request request) async { return Response.ok( body: + Body.fromString( jsonEncode({'status': 'healthy', 'timestamp': + DateTime.now().toIso8601String()}), mimeType: MimeType.json, ), ); } + + @override Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); } } + +// Register the entire API module pod.webServer.addRoute(ApiModule(), '/api'); ``` This pattern enables you to: - **Organize routes hierarchically** - Group related functionality together - **Reuse route modules** - Use the same module in different applications - **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler implementation +- **Separate concerns** - Keep route registration logic separate from handler + implementation -:::tip -When overriding `injectIn`, you typically don't need to implement `handleCall` since you're registering handler functions directly with the router. You can throw `UnimplementedError` in `handleCall` to make it clear the method isn't used. -::: +:::tip When overriding `injectIn`, you typically don't need to implement +`handleCall` since you're registering handler functions directly with the +router. You can throw `UnimplementedError` in `handleCall` to make it clear the +method isn't used. ::: ## Middleware -Routes handle the core logic of your application, but many concerns cut across multiple routes: logging every request, validating API keys, handling CORS headers, or catching errors. Rather than duplicating this code in each route, middleware lets you apply it globally or to specific path prefixes. +Routes handle the core logic of your application, but many concerns cut across +multiple routes: logging every request, validating API keys, handling CORS +headers, or catching errors. Rather than duplicating this code in each route, +middleware lets you apply it globally or to specific path prefixes. -Middleware functions are wrappers that sit between the incoming request and your route handler. They can inspect or modify requests before they reach your routes, and transform responses before they're sent back to the client. This makes middleware perfect for authentication, logging, error handling, and any other cross-cutting concern in your web server. +Middleware functions are wrappers that sit between the incoming request and your +route handler. They can inspect or modify requests before they reach your +routes, and transform responses before they're sent back to the client. This +makes middleware perfect for authentication, logging, error handling, and any +other cross-cutting concern in your web server. ### Adding middleware Use the `addMiddleware` method to apply middleware to specific path prefixes: -```dart -// Apply to all routes -pod.webServer.addMiddleware(loggingMiddleware, '/'); +```dart // Apply to all routes pod.webServer.addMiddleware(loggingMiddleware, +'/'); -// Apply only to API routes -pod.webServer.addMiddleware(authMiddleware, '/api'); +// Apply only to API routes pod.webServer.addMiddleware(authMiddleware, '/api'); ``` ### Creating custom middleware -Middleware is a function that takes a `Handler` and returns a new `Handler`. Here's a simple logging middleware example: +Middleware is a function that takes a `Handler` and returns a new `Handler`. +Here's a simple logging middleware example: -```dart -Handler loggingMiddleware(Handler innerHandler) { - return (Request request) async { - final start = DateTime.now(); - print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); +```dart Handler loggingMiddleware(Handler innerHandler) { return (Request +request) async { final start = DateTime.now(); print('→ +${request.method.name.toUpperCase()} ${request.url.path}'); - // Call the next handler in the chain - final response = await innerHandler(request); + // Call the next handler in the chain final response = await + innerHandler(request); - final duration = DateTime.now().difference(start); - print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); + final duration = DateTime.now().difference(start); print('← + ${response.statusCode} (${duration.inMilliseconds}ms)'); - return response; - }; -} -``` + return response; }; } ``` ### API key validation middleware A common use case is validating API keys for protected routes: -```dart -Handler apiKeyMiddleware(Handler innerHandler) { - return (Request request) async { - // Check for API key in header - final apiKey = request.headers['X-API-Key']?.firstOrNull; +```dart Handler apiKeyMiddleware(Handler innerHandler) { return (Request +request) async { // Check for API key in header final apiKey = +request.headers['X-API-Key']?.firstOrNull; - if (apiKey == null) { - return Response.unauthorized( - body: Body.fromString('API key required'), - ); - } + if (apiKey == null) { return Response.unauthorized( body: + Body.fromString('API key required'), ); } - // Verify API key - if (!await isValidApiKey(apiKey)) { - return Response.forbidden( - body: Body.fromString('Invalid API key'), - ); - } + // Verify API key if (!await isValidApiKey(apiKey)) { return + Response.forbidden( body: Body.fromString('Invalid API key'), ); } - // Continue to the next handler - return await innerHandler(request); - }; -} + // Continue to the next handler return await innerHandler(request); }; } -// Apply to protected routes -pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); -``` +// Apply to protected routes pod.webServer.addMiddleware(apiKeyMiddleware, +'/api'); ``` -:::info -For user authentication, use Serverpod's built-in authentication system which integrates with the `Session` object. The middleware examples here are for additional web-specific validations like API keys, rate limiting, or request validation. -::: +:::info For user authentication, use Serverpod's built-in authentication system +which integrates with the `Session` object. The middleware examples here are for +additional web-specific validations like API keys, rate limiting, or request +validation. ::: ### CORS middleware Enable Cross-Origin Resource Sharing for your API: -```dart -Handler corsMiddleware(Handler innerHandler) { - return (Request request) async { - // Handle preflight requests - if (request.method == Method.options) { - return Response.ok( - headers: Headers.build((h) { - h.set('Access-Control-Allow-Origin', '*'); - h.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); - h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - }), - ); - } +```dart Handler corsMiddleware(Handler innerHandler) { return (Request request) +async { // Handle preflight requests if (request.method == Method.options) { +return Response.ok( headers: Headers.build((h) { +h.set('Access-Control-Allow-Origin', '*'); h.set('Access-Control-Allow-Methods', +'GET, POST, PUT, DELETE'); h.set('Access-Control-Allow-Headers', 'Content-Type, +Authorization'); }), ); } - // Process the request - final response = await innerHandler(request); + // Process the request final response = await innerHandler(request); - // Add CORS headers to response - return response.change( - headers: Headers.build((h) { - h.set('Access-Control-Allow-Origin', '*'); - }), - ); - }; -} + // Add CORS headers to response return response.change( headers: + Headers.build((h) { h.set('Access-Control-Allow-Origin', '*'); }), ); }; } -pod.webServer.addMiddleware(corsMiddleware, '/api'); -``` +pod.webServer.addMiddleware(corsMiddleware, '/api'); ``` ### Error handling middleware -A global error handling middleware can catch exceptions from routes and return appropriate error responses: - -```dart -Handler errorHandlingMiddleware(Handler innerHandler) { - return (Request request) async { - try { - return await innerHandler(request); - } on FormatException catch (e) { - // Handle JSON parsing errors - return Response.badRequest( - body: Body.fromString( - jsonEncode({'error': 'Invalid request format', 'message': e.message}), - mimeType: MimeType.json, - ), - ); - } catch (e, stackTrace) { - // Log the error - print('Error handling ${request.method} ${request.url.path}: $e'); - print(stackTrace); +A global error handling middleware can catch exceptions from routes and return +appropriate error responses: + +```dart Handler errorHandlingMiddleware(Handler innerHandler) { return (Request +request) async { try { return await innerHandler(request); } on FormatException +catch (e) { // Handle JSON parsing errors return Response.badRequest( body: +Body.fromString( jsonEncode({'error': 'Invalid request format', 'message': +e.message}), mimeType: MimeType.json, ), ); } catch (e, stackTrace) { // Log the +error print('Error handling ${request.method} ${request.url.path}: $e'); +print(stackTrace); - // Return a generic error response - return Response.internalServerError( - body: Body.fromString( - jsonEncode({'error': 'Internal server error'}), - mimeType: MimeType.json, - ), - ); - } - }; -} + // Return a generic error response return Response.internalServerError( + body: Body.fromString( jsonEncode({'error': 'Internal server error'}), + mimeType: MimeType.json, ), ); } }; } -// Apply to all routes -pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); -``` +// Apply to all routes pod.webServer.addMiddleware(errorHandlingMiddleware, +'/'); ``` -With error handling middleware in place, your route handlers can focus on business logic without extensive try-catch blocks. Uncaught exceptions will be caught and converted to appropriate HTTP responses. +With error handling middleware in place, your route handlers can focus on +business logic without extensive try-catch blocks. Uncaught exceptions will be +caught and converted to appropriate HTTP responses. ### Middleware execution order -Middleware is applied based on path hierarchy, with more specific paths taking precedence. Within the same path, middleware executes in the order it was registered: +Middleware is applied based on path hierarchy, with more specific paths taking +precedence. Within the same path, middleware executes in the order it was +registered: -```dart -pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) -pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes -pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes -``` +```dart pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first +(outer) pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second +(inner) for /api routes pod.webServer.addMiddleware(rateLimitMiddleware, +'/api'); // Executes third (innermost) for /api routes ``` For a request to `/api/users`, the execution order is: 1. `loggingMiddleware` (before) @@ -764,20 +594,33 @@ For a request to `/api/users`, the execution order is: ### Request-scoped data with ContextProperty -Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a subdomain, or a logging middleware might generate a request ID for tracing. Since `Request` objects are immutable, you can't just add properties to them. This is where `ContextProperty` comes in. +Middleware often needs to pass computed data to downstream handlers. For +example, a tenant identification middleware might extract the tenant ID from a +subdomain, or a logging middleware might generate a request ID for tracing. +Since `Request` objects are immutable, you can't just add properties to them. +This is where `ContextProperty` comes in. -`ContextProperty` provides a type-safe way to attach data to a `Request` object without modifying it. Think of it as a side channel for request-scoped data that middleware can write to and routes can read from. The data is automatically cleaned up when the request completes, making it perfect for per-request state. +`ContextProperty` provides a type-safe way to attach data to a `Request` +object without modifying it. Think of it as a side channel for request-scoped +data that middleware can write to and routes can read from. The data is +automatically cleaned up when the request completes, making it perfect for +per-request state. -:::info -Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard Session. -::: +:::info Note that Serverpod's `Route.handleCall()` already receives a `Session` +parameter which includes authenticated user information if available. Use +`ContextProperty` for web-specific request data that isn't part of the standard +Session. ::: #### Why use ContextProperty? -Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that can be accessed by all downstream middleware and handlers. Common use cases include: +Since `Request` objects are immutable, you can't modify them directly. +`ContextProperty` allows you to associate additional data with a request that +can be accessed by all downstream middleware and handlers. Common use cases +include: - **Request ID tracking** - Add correlation IDs for logging and tracing -- **Tenant identification** - Multi-tenant application context from subdomains/headers +- **Tenant identification** - Multi-tenant application context from + subdomains/headers - **Feature flags** - Request-specific feature toggles based on headers/cookies - **Rate limiting** - Store rate limit state per request - **API versioning** - Extract and store API version from headers @@ -786,273 +629,195 @@ Since `Request` objects are immutable, you can't modify them directly. `ContextP Define a `ContextProperty` as a top-level constant or static field: -```dart -// Define a property for request ID tracking -final requestIdProperty = ContextProperty(); +```dart // Define a property for request ID tracking final requestIdProperty = +ContextProperty(); -// Define a property for tenant identification -final tenantProperty = ContextProperty(); +// Define a property for tenant identification final tenantProperty = +ContextProperty(); -// Optional: with a default value -final featureFlagsProperty = ContextProperty( - defaultValue: () => FeatureFlags.defaults(), -); +// Optional: with a default value final featureFlagsProperty = +ContextProperty( defaultValue: () => FeatureFlags.defaults(), ); ``` #### Setting values in middleware -Middleware can set values on the context property, making them available to all downstream handlers: +Middleware can set values on the context property, making them available to all +downstream handlers: -```dart -final requestIdProperty = ContextProperty(); +```dart final requestIdProperty = ContextProperty(); -Handler requestIdMiddleware(Handler innerHandler) { - return (Request request) async { - // Generate a unique request ID for tracing - final requestId = Uuid().v4(); +Handler requestIdMiddleware(Handler innerHandler) { return (Request request) +async { // Generate a unique request ID for tracing final requestId = +Uuid().v4(); - // Attach to request context - requestIdProperty[request] = requestId; + // Attach to request context requestIdProperty[request] = requestId; - // Log the incoming request - print('[$requestId] ${request.method} ${request.url.path}'); + // Log the incoming request print('[$requestId] ${request.method} + ${request.url.path}'); - // Continue to next handler - final response = await innerHandler(request); + // Continue to next handler final response = await innerHandler(request); - // Log the response - print('[$requestId] Response: ${response.statusCode}'); + // Log the response print('[$requestId] Response: ${response.statusCode}'); - // Optionally add request ID to response headers - return response.change( - headers: {'X-Request-ID': requestId}, - ); - }; -} -``` + // Optionally add request ID to response headers return response.change( + headers: {'X-Request-ID': requestId}, ); }; } ``` #### Accessing values in routes Route handlers can retrieve the value from the context property: -```dart -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Get the request ID from context - final requestId = requestIdProperty[request]; - - // Use it for logging or tracing - session.log('Processing request $requestId'); - - // Your route logic here - final data = await processRequest(session); - - return Response.ok( - body: Body.fromString( - jsonEncode(data), - mimeType: MimeType.json, - ), - ); - } -} -``` +```dart class ApiRoute extends Route { @override Future +handleCall(Session session, Request request) async { // Get the request ID from +context final requestId = requestIdProperty[request]; + + // Use it for logging or tracing session.log('Processing request + $requestId'); + + // Your route logic here final data = await processRequest(session); + + return Response.ok( body: Body.fromString( jsonEncode(data), mimeType: + MimeType.json, ), ); } } ``` #### Safe access with getOrNull If a value might not be set, use `getOrNull()` to avoid exceptions: -```dart -class TenantRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Safely get tenant, returns null if not set - final tenant = tenantProperty.getOrNull(request); - - if (tenant != null) { - // Fetch tenant-specific data - final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); - return Response.ok( - body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), - ); - } else { - return Response.badRequest( - body: Body.fromString('Missing tenant identifier'), - ); - } - } -} -``` +```dart class TenantRoute extends Route { @override Future +handleCall(Session session, Request request) async { // Safely get tenant, +returns null if not set final tenant = tenantProperty.getOrNull(request); + + if (tenant != null) { // Fetch tenant-specific data final data = await + session.db.find(where: (t) => t.tenantId.equals(tenant)); return + Response.ok( body: Body.fromString(jsonEncode(data), mimeType: + MimeType.json), ); } else { return Response.badRequest( body: + Body.fromString('Missing tenant identifier'), ); } } } ``` #### Complete multi-tenant example Here's a complete example showing tenant identification from subdomains: -```dart -// Define the context property for tenant ID -final tenantProperty = ContextProperty(); +```dart // Define the context property for tenant ID final tenantProperty = +ContextProperty(); -// Tenant identification middleware (extracts from subdomain) -Handler tenantMiddleware(Handler innerHandler) { - return (Request request) async { - final host = request.headers.host; +// Tenant identification middleware (extracts from subdomain) Handler +tenantMiddleware(Handler innerHandler) { return (Request request) async { final +host = request.headers.host; - if (host == null) { - return Response.badRequest( - body: Body.fromString('Missing host header'), - ); - } + if (host == null) { return Response.badRequest( body: + Body.fromString('Missing host header'), ); } - // Extract tenant from subdomain (e.g., acme.example.com -> "acme") - final parts = host.host.split('.'); - if (parts.length < 2) { - return Response.badRequest( - body: Body.fromString('Invalid hostname format'), - ); - } + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") final + parts = host.host.split('.'); if (parts.length < 2) { return + Response.badRequest( body: Body.fromString('Invalid hostname format'), ); } final tenant = parts.first; - // Validate tenant exists (implement your own logic) - final session = request.session; - final tenantExists = await validateTenant(session, tenant); + // Validate tenant exists (implement your own logic) final session = + request.session; final tenantExists = await validateTenant(session, tenant); - if (!tenantExists) { - return Response.notFound( - body: Body.fromString('Tenant not found'), - ); - } + if (!tenantExists) { return Response.notFound( body: Body.fromString('Tenant + not found'), ); } - // Attach tenant to context - tenantProperty[request] = tenant; - - return await innerHandler(request); - }; -} - -// Usage in your server -pod.webServer.addMiddleware(tenantMiddleware, '/'); - -// Routes automatically have access to the tenant -class TenantDataRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final tenant = tenantProperty[request]; - - // Fetch tenant-specific data - final data = await session.db.find( - where: (p) => p.tenantId.equals(tenant), - ); - - return Response.ok( - body: Body.fromString( - jsonEncode(data.map((p) => p.toJson()).toList()), - mimeType: MimeType.json, - ), - ); - } -} -``` + // Attach tenant to context tenantProperty[request] = tenant; + + return await innerHandler(request); }; } + +// Usage in your server pod.webServer.addMiddleware(tenantMiddleware, '/'); + +// Routes automatically have access to the tenant class TenantDataRoute extends +Route { @override Future handleCall(Session session, Request request) +async { final tenant = tenantProperty[request]; + + // Fetch tenant-specific data final data = await session.db.find( + where: (p) => p.tenantId.equals(tenant), ); + + return Response.ok( body: Body.fromString( jsonEncode(data.map((p) => + p.toJson()).toList()), mimeType: MimeType.json, ), ); } } ``` #### Multiple context properties You can use multiple context properties for different types of data: -```dart -final requestIdProperty = ContextProperty(); -final tenantProperty = ContextProperty(); -final apiVersionProperty = ContextProperty(); - -Handler requestContextMiddleware(Handler innerHandler) { - return (Request request) async { - // Generate and attach request ID - final requestId = Uuid().v4(); - requestIdProperty[request] = requestId; - - // Extract tenant from subdomain or header - final host = request.headers.host; - if (host != null) { - final tenant = host.host.split('.').first; - tenantProperty[request] = tenant; - } +```dart final requestIdProperty = ContextProperty(); final +tenantProperty = ContextProperty(); final apiVersionProperty = +ContextProperty(); + +Handler requestContextMiddleware(Handler innerHandler) { return (Request +request) async { // Generate and attach request ID final requestId = +Uuid().v4(); requestIdProperty[request] = requestId; - // Extract API version from header - final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; + // Extract tenant from subdomain or header final host = + request.headers.host; if (host != null) { final tenant = + host.host.split('.').first; tenantProperty[request] = tenant; } + + // Extract API version from header final apiVersion = + request.headers['X-API-Version']?.firstOrNull ?? '1.0'; apiVersionProperty[request] = apiVersion; - return await innerHandler(request); - }; -} + return await innerHandler(request); }; } -// Later in your route -class DataRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final requestId = requestIdProperty[request]; - final tenant = tenantProperty[request]; - final apiVersion = apiVersionProperty[request]; +// Later in your route class DataRoute extends Route { @override Future +handleCall(Session session, Request request) async { final requestId = +requestIdProperty[request]; final tenant = tenantProperty[request]; final +apiVersion = apiVersionProperty[request]; session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); - // Fetch tenant-specific data - final data = await fetchTenantData(session, tenant); + // Fetch tenant-specific data final data = await fetchTenantData(session, + tenant); - return Response.ok( - body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), - ); - } -} -``` + return Response.ok( body: Body.fromString(jsonEncode(data), mimeType: + MimeType.json), ); } } ``` :::tip Best Practices - Define `ContextProperty` instances as top-level constants or static fields -- Use descriptive names for your properties (e.g., `requestIdProperty`, not just `requestId`) +- Use descriptive names for your properties (e.g., `requestIdProperty`, not just + `requestId`) - Use `getOrNull()` when the value might not be set - Set properties in middleware, not in routes -- Use specific types for better type safety -::: +- Use specific types for better type safety ::: ### Built-in logging middleware -Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient request logging: +Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient +request logging: -```dart -import 'package:serverpod/serverpod.dart'; +```dart import 'package:serverpod/serverpod.dart'; -pod.webServer.addMiddleware(logRequests(), '/'); -``` +pod.webServer.addMiddleware(logRequests(), '/'); ``` This logs all requests with method, path, status code, and response time. ## Typed headers -HTTP headers are traditionally accessed as strings, which means you need to manually parse values, handle edge cases, and validate formats. Serverpod's web server (via Relic) provides a better approach: typed headers that automatically parse header values into strongly-typed Dart objects. +HTTP headers are traditionally accessed as strings, which means you need to +manually parse values, handle edge cases, and validate formats. Serverpod's web +server (via Relic) provides a better approach: typed headers that automatically +parse header values into strongly-typed Dart objects. -For example, instead of parsing `Authorization: Bearer abc123` as a string and extracting the token yourself, you can access `request.headers.authorization` to get a `BearerAuthorizationHeader` object with the token already parsed and validated. This eliminates boilerplate code, catches errors early, and makes your code more maintainable. +For example, instead of parsing `Authorization: Bearer abc123` as a string and +extracting the token yourself, you can access `request.headers.authorization` to +get a `BearerAuthorizationHeader` object with the token already parsed and +validated. This eliminates boilerplate code, catches errors early, and makes +your code more maintainable. ### Reading typed headers Access typed headers through extension methods on `request.headers`: -```dart -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Type-safe accessors return parsed objects or null - final auth = request.headers.authorization; // AuthorizationHeader? - final contentType = request.headers.contentType; // ContentTypeHeader? - final cookies = request.headers.cookie; // CookieHeader? - final userAgent = request.headers.userAgent; // String? - final host = request.headers.host; // HostHeader? - - // Raw string access is also available for any header - final authRaw = request.headers['Authorization']; // Iterable? - final custom = request.headers['X-Custom-Header']; // Iterable? - - return Response.ok(); - } -} -``` +```dart class ApiRoute extends Route { @override Future +handleCall(Session session, Request request) async { // Type-safe accessors +return parsed objects or null final auth = request.headers.authorization; // +AuthorizationHeader? final contentType = request.headers.contentType; // +ContentTypeHeader? final cookies = request.headers.cookie; // CookieHeader? +final userAgent = request.headers.userAgent; // String? final host = +request.headers.host; // HostHeader? + + // Raw string access is also available for any header final authRaw = + request.headers['Authorization']; // Iterable? final custom = + request.headers['X-Custom-Header']; // Iterable? + + return Response.ok(); } } ``` Common request headers include: - `authorization` - AuthorizationHeader (Bearer/Basic/Digest) @@ -1071,24 +836,12 @@ Common request headers include: Set typed headers in responses using the `Headers.build()` builder pattern: -```dart -return Response.ok( - headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 3600, - publicCache: true, - ); - h.contentType = ContentTypeHeader( - mimeType: MimeType.json, - charset: 'utf-8', - ); - - // Set custom headers (raw) - h['X-API-Version'] = ['2.0']; - }), - body: Body.fromString(jsonEncode(data)), -); -``` +```dart return Response.ok( headers: Headers.build((h) { h.cacheControl = +CacheControlHeader( maxAge: 3600, publicCache: true, ); h.contentType = +ContentTypeHeader( mimeType: MimeType.json, charset: 'utf-8', ); + + // Set custom headers (raw) h['X-API-Version'] = ['2.0']; }), body: + Body.fromString(jsonEncode(data)), ); ``` Common response headers include: - `cacheControl` - CacheControlHeader @@ -1102,66 +855,38 @@ Common response headers include: The `AuthorizationHeader` supports three authentication schemes: -**Bearer Token (JWT, OAuth):** -```dart -final auth = request.headers.authorization; +**Bearer Token (JWT, OAuth):** ```dart final auth = +request.headers.authorization; -if (auth is BearerAuthorizationHeader) { - final token = auth.token; // The actual token string +if (auth is BearerAuthorizationHeader) { final token = auth.token; // The actual +token string - // Validate token - if (!await validateToken(token)) { - return Response.unauthorized(); - } -} -``` + // Validate token if (!await validateToken(token)) { return + Response.unauthorized(); } } ``` -**Basic Authentication:** -```dart -if (auth is BasicAuthorizationHeader) { - final username = auth.username; - final password = auth.password; +**Basic Authentication:** ```dart if (auth is BasicAuthorizationHeader) { final +username = auth.username; final password = auth.password; - // Validate credentials - if (!await validateCredentials(username, password)) { - return Response.unauthorized(); - } -} -``` + // Validate credentials if (!await validateCredentials(username, password)) { + return Response.unauthorized(); } } ``` -**Setting Bearer token:** -```dart -headers: Headers.build((h) { - h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); -}), -``` +**Setting Bearer token:** ```dart headers: Headers.build((h) { h.authorization = +BearerAuthorizationHeader(token: 'eyJhbGc...'); }), ``` ### CacheControlHeader - Cache directives Control caching behavior with type-safe cache directives: -```dart -// Public cache with 1 hour expiration -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 3600, // Cache for 1 hour - publicCache: true, // Shared cache allowed - mustRevalidate: true, // Must revalidate after expiry - staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating - ); -}), -``` +```dart // Public cache with 1 hour expiration headers: Headers.build((h) { +h.cacheControl = CacheControlHeader( maxAge: 3600, // Cache for 1 hour +publicCache: true, // Shared cache allowed mustRevalidate: true, // Must +revalidate after expiry staleWhileRevalidate: 86400, // Can use stale for 1 day +while revalidating ); }), ``` -```dart -// Secure defaults for sensitive data -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - noStore: true, // Don't store anywhere - noCache: true, // Must revalidate - privateCache: true, // Only private cache - ); -}), -``` +```dart // Secure defaults for sensitive data headers: Headers.build((h) { +h.cacheControl = CacheControlHeader( noStore: true, // Don't store anywhere +noCache: true, // Must revalidate privateCache: true, // Only private cache ); +}), ``` Available directives: - `noCache`, `noStore` - Cache control flags @@ -1175,65 +900,32 @@ Available directives: Specify how content should be handled (inline display or download): -```dart -// File download with proper filename -headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter(name: 'filename', value: 'report.pdf'), - ], - ); -}), +```dart // File download with proper filename headers: Headers.build((h) { +h.contentDisposition = ContentDispositionHeader( type: 'attachment', parameters: +[ ContentDispositionParameter(name: 'filename', value: 'report.pdf'), ], ); }), ``` -```dart -// With extended encoding (RFC 5987) for non-ASCII filenames -h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: 'rapport.pdf', - isExtended: true, - encoding: 'UTF-8', - ), - ], -); -``` +```dart // With extended encoding (RFC 5987) for non-ASCII filenames +h.contentDisposition = ContentDispositionHeader( type: 'attachment', parameters: +[ ContentDispositionParameter( name: 'filename', value: 'rapport.pdf', +isExtended: true, encoding: 'UTF-8', ), ], ); ``` ### CookieHeader and SetCookieHeader - Cookies -**Reading cookies from requests:** -```dart -final cookieHeader = request.headers.cookie; +**Reading cookies from requests:** ```dart final cookieHeader = +request.headers.cookie; -if (cookieHeader != null) { - // Find a specific cookie - final sessionId = cookieHeader.getCookie('session_id')?.value; +if (cookieHeader != null) { // Find a specific cookie final sessionId = +cookieHeader.getCookie('session_id')?.value; - // Iterate all cookies - for (final cookie in cookieHeader.cookies) { - print('${cookie.name}=${cookie.value}'); - } -} -``` + // Iterate all cookies for (final cookie in cookieHeader.cookies) { + print('${cookie.name}=${cookie.value}'); } } ``` -**Setting cookies in responses:** -```dart -headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: '12345abcde', - maxAge: 3600, // 1 hour - path: Uri.parse('/'), - domain: Uri.parse('example.com'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.strict, // CSRF protection - ); -}), -``` +**Setting cookies in responses:** ```dart headers: Headers.build((h) { +h.setCookie = SetCookieHeader( name: 'session_id', value: '12345abcde', maxAge: +3600, // 1 hour path: Uri.parse('/'), domain: Uri.parse('example.com'), secure: +true, // HTTPS only httpOnly: true, // No JavaScript access sameSite: +SameSite.strict, // CSRF protection ); }), ``` SameSite values: - `SameSite.lax` - Default, not sent on cross-site requests (except navigation) @@ -1242,287 +934,171 @@ SameSite values: ### Complete examples -**Secure API with authentication and caching:** -```dart -class SecureApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Check authorization - final auth = request.headers.authorization; - if (auth is! BearerAuthorizationHeader) { - return Response.unauthorized(); - } - - // Validate token - if (!await validateToken(auth.token)) { - return Response.forbidden(); - } - - // Return data with cache headers - return Response.ok( - headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 300, // 5 minutes - publicCache: true, - mustRevalidate: true, - ); - h.contentType = ContentTypeHeader( - mimeType: MimeType.json, - charset: 'utf-8', - ); - }), - body: Body.fromString(jsonEncode(data)), - ); - } -} -``` - -**File download with proper headers:** -```dart -class DownloadRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final fileId = request.pathParameters[#fileId]; - final file = await getFile(session, fileId); - - return Response.ok( - headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: file.name, - isExtended: true, - encoding: 'UTF-8', - ), - ], - ); - h.contentType = ContentTypeHeader( - mimeType: file.mimeType, - ); - h.cacheControl = CacheControlHeader( - noCache: true, - mustRevalidate: true, - ); - }), - body: Body.fromBytes(file.content), - ); - } -} -``` - -**Cookie-based sessions:** -```dart -class LoginRoute extends Route { - LoginRoute() : super(methods: {Method.post}); - - @override - Future handleCall(Session session, Request request) async { - // Authenticate user... - final sessionToken = await authenticateAndCreateSession(session, request); - - return Response.ok( - headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: sessionToken, - maxAge: 86400, // 24 hours - path: Uri.parse('/'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.lax, // CSRF protection - ); - }), - body: Body.fromString( - jsonEncode({'status': 'logged_in'}), - mimeType: MimeType.json, - ), - ); - } -} -``` +**Secure API with authentication and caching:** ```dart class SecureApiRoute +extends Route { @override Future handleCall(Session session, Request +request) async { // Check authorization final auth = +request.headers.authorization; if (auth is! BearerAuthorizationHeader) { return +Response.unauthorized(); } + + // Validate token if (!await validateToken(auth.token)) { return + Response.forbidden(); } + + // Return data with cache headers return Response.ok( headers: + Headers.build((h) { h.cacheControl = CacheControlHeader( maxAge: 300, // 5 + minutes publicCache: true, mustRevalidate: true, ); h.contentType = + ContentTypeHeader( mimeType: MimeType.json, charset: 'utf-8', ); }), body: + Body.fromString(jsonEncode(data)), ); } } ``` + +**File download with proper headers:** ```dart class DownloadRoute extends Route +{ @override Future handleCall(Session session, Request request) async { +final fileId = request.pathParameters[#fileId]; final file = await +getFile(session, fileId); + + return Response.ok( headers: Headers.build((h) { h.contentDisposition = + ContentDispositionHeader( type: 'attachment', parameters: [ + ContentDispositionParameter( name: 'filename', value: file.name, isExtended: + true, encoding: 'UTF-8', ), ], ); h.contentType = ContentTypeHeader( + mimeType: file.mimeType, ); h.cacheControl = CacheControlHeader( noCache: + true, mustRevalidate: true, ); }), body: Body.fromBytes(file.content), ); } + } ``` + +**Cookie-based sessions:** ```dart class LoginRoute extends Route { LoginRoute() +: super(methods: {Method.post}); + + @override Future handleCall(Session session, Request request) async { + // Authenticate user... final sessionToken = await + authenticateAndCreateSession(session, request); + + return Response.ok( headers: Headers.build((h) { h.setCookie = + SetCookieHeader( name: 'session_id', value: sessionToken, maxAge: 86400, // + 24 hours path: Uri.parse('/'), secure: true, // HTTPS only httpOnly: true, + // No JavaScript access sameSite: SameSite.lax, // CSRF protection ); }), + body: Body.fromString( jsonEncode({'status': 'logged_in'}), mimeType: + MimeType.json, ), ); } } ``` :::tip Best Practices - Use typed headers for automatic parsing and validation - Set appropriate cache headers for better performance - Use `secure: true` and `httpOnly: true` for sensitive cookies - Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection -::: +- Use `SameSite` cookie attribute for CSRF protection ::: ### Creating custom typed headers -While Relic provides typed headers for all standard HTTP headers, your application may use custom headers for API versioning, feature flags, or application-specific metadata. Rather than falling back to string-based access for these custom headers, you can create your own typed headers using the same pattern Relic uses internally. +While Relic provides typed headers for all standard HTTP headers, your +application may use custom headers for API versioning, feature flags, or +application-specific metadata. Rather than falling back to string-based access +for these custom headers, you can create your own typed headers using the same +pattern Relic uses internally. -Creating a custom typed header involves three steps: defining the header class with parsing logic, creating a codec for serialization, and setting up a `HeaderAccessor` for type-safe access. Once configured, your custom headers work just like the built-in ones, with automatic parsing, validation, and convenient property-style access. +Creating a custom typed header involves three steps: defining the header class +with parsing logic, creating a codec for serialization, and setting up a +`HeaderAccessor` for type-safe access. Once configured, your custom headers work +just like the built-in ones, with automatic parsing, validation, and convenient +property-style access. Here's a complete example for a custom `X-API-Version` header: -```dart -// Define your typed header class -final class ApiVersionHeader { - final int major; - final int minor; +```dart // Define your typed header class final class ApiVersionHeader { final +int major; final int minor; ApiVersionHeader({required this.major, required this.minor}); - // Parse from string format "1.2" - factory ApiVersionHeader.parse(String value) { - final parts = value.split('.'); - if (parts.length != 2) { - throw const FormatException('Invalid API version format'); - } + // Parse from string format "1.2" factory ApiVersionHeader.parse(String value) + { final parts = value.split('.'); if (parts.length != 2) { throw const + FormatException('Invalid API version format'); } - final major = int.tryParse(parts[0]); - final minor = int.tryParse(parts[1]); + final major = int.tryParse(parts[0]); final minor = int.tryParse(parts[1]); - if (major == null || minor == null) { - throw const FormatException('Invalid API version numbers'); - } + if (major == null || minor == null) { throw const FormatException('Invalid + API version numbers'); } - return ApiVersionHeader(major: major, minor: minor); - } + return ApiVersionHeader(major: major, minor: minor); } - // Encode to string format - String encode() => '$major.$minor'; + // Encode to string format String encode() => '$major.$minor'; - @override - bool operator ==(Object other) => - identical(this, other) || - other is ApiVersionHeader && - major == other.major && - minor == other.minor; + @override bool operator ==(Object other) => identical(this, other) || other is + ApiVersionHeader && major == other.major && minor == other.minor; - @override - int get hashCode => Object.hash(major, minor); + @override int get hashCode => Object.hash(major, minor); - @override - String toString() => 'ApiVersionHeader($major.$minor)'; -} + @override String toString() => 'ApiVersionHeader($major.$minor)'; } -// Create a HeaderCodec for encoding/decoding -const _apiVersionCodec = HeaderCodec.single( - ApiVersionHeader.parse, - (ApiVersionHeader value) => [value.encode()], -); +// Create a HeaderCodec for encoding/decoding const _apiVersionCodec = +HeaderCodec.single( ApiVersionHeader.parse, (ApiVersionHeader value) => +[value.encode()], ); -// Create a global HeaderAccessor -const apiVersionHeader = HeaderAccessor( - 'x-api-version', - _apiVersionCodec, -); -``` +// Create a global HeaderAccessor const apiVersionHeader = HeaderAccessor( +'x-api-version', _apiVersionCodec, ); ``` **Using your custom header:** -```dart -// Reading the header -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final version = apiVersionHeader[request.headers](); - - if (version != null && version.major < 2) { - return Response.badRequest( - body: Body.fromString('API version 2.0 or higher required'), - ); - } +```dart // Reading the header class ApiRoute extends Route { @override +Future handleCall(Session session, Request request) async { final +version = apiVersionHeader[request.headers](); + + if (version != null && version.major < 2) { return Response.badRequest( + body: Body.fromString('API version 2.0 or higher required'), ); } - return Response.ok(); - } -} + return Response.ok(); } } -// Setting the header -return Response.ok( - headers: Headers.build((h) { - apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); - }), -); -``` +// Setting the header return Response.ok( headers: Headers.build((h) { +apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); }), ); ``` **Multi-value header example:** For headers that can have multiple comma-separated values: -```dart -final class CustomTagsHeader { - final List tags; +```dart final class CustomTagsHeader { final List tags; - CustomTagsHeader({required List tags}) - : tags = List.unmodifiable(tags); + CustomTagsHeader({required List tags}) : tags = + List.unmodifiable(tags); - // Parse from multiple values or comma-separated - factory CustomTagsHeader.parse(Iterable values) { - final allTags = values - .expand((v) => v.split(',')) - .map((t) => t.trim()) - .where((t) => t.isNotEmpty) - .toSet() - .toList(); + // Parse from multiple values or comma-separated factory + CustomTagsHeader.parse(Iterable values) { final allTags = values + .expand((v) => v.split(',')) .map((t) => t.trim()) .where((t) => t.isNotEmpty) + .toSet() .toList(); - if (allTags.isEmpty) { - throw const FormatException('Tags cannot be empty'); + if (allTags.isEmpty) { throw const FormatException('Tags cannot be empty'); } - return CustomTagsHeader(tags: allTags); - } + return CustomTagsHeader(tags: allTags); } List encode() => [tags.join(', ')]; - @override - bool operator ==(Object other) => - identical(this, other) || - other is CustomTagsHeader && - const ListEquality().equals(tags, other.tags); + @override bool operator ==(Object other) => identical(this, other) || other is + CustomTagsHeader && const ListEquality().equals(tags, other.tags); - @override - int get hashCode => const ListEquality().hash(tags); -} + @override int get hashCode => const ListEquality().hash(tags); } -// Use HeaderCodec (not HeaderCodec.single) for multi-value -const _customTagsCodec = HeaderCodec( - CustomTagsHeader.parse, - (CustomTagsHeader value) => value.encode(), -); +// Use HeaderCodec (not HeaderCodec.single) for multi-value const +_customTagsCodec = HeaderCodec( CustomTagsHeader.parse, (CustomTagsHeader value) +=> value.encode(), ); -const customTagsHeader = HeaderAccessor( - 'x-custom-tags', - _customTagsCodec, -); +const customTagsHeader = HeaderAccessor( 'x-custom-tags', _customTagsCodec, ); ``` **Optional: Add extension methods for convenient access** -For better ergonomics, you can add extension methods to access your custom headers with property syntax: +For better ergonomics, you can add extension methods to access your custom +headers with property syntax: -```dart -extension CustomHeadersEx on Headers { - ApiVersionHeader? get apiVersion => apiVersionHeader[this](); - CustomTagsHeader? get customTags => customTagsHeader[this](); -} +```dart extension CustomHeadersEx on Headers { ApiVersionHeader? get apiVersion +=> apiVersionHeader[this](); CustomTagsHeader? get customTags => +customTagsHeader[this](); } -extension CustomMutableHeadersEx on MutableHeaders { - set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); - set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); -} -``` +extension CustomMutableHeadersEx on MutableHeaders { set +apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); set +customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); } ``` Now you can use property syntax instead of the bracket notation: -```dart -// Reading with property syntax -final version = request.headers.apiVersion; -final tags = request.headers.customTags; - -// Setting with property syntax -return Response.ok( - headers: Headers.build((h) { - h.apiVersion = ApiVersionHeader(major: 2, minor: 1); - h.customTags = CustomTagsHeader(tags: ['production', 'v2']); - }), -); -``` +```dart // Reading with property syntax final version = +request.headers.apiVersion; final tags = request.headers.customTags; + +// Setting with property syntax return Response.ok( headers: Headers.build((h) { +h.apiVersion = ApiVersionHeader(major: 2, minor: 1); h.customTags = +CustomTagsHeader(tags: ['production', 'v2']); }), ); ``` **Key points:** diff --git a/package-lock.json b/package-lock.json index 53258002..85180334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -235,6 +235,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.40.1.tgz", "integrity": "sha512-Mw6pAUF121MfngQtcUb5quZVqMC68pSYYjCRZkSITC085S3zdk+h/g7i6FxnVdbSU6OztxikSDMh1r7Z+4iPlA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.40.1", "@algolia/requester-browser-xhr": "5.40.1", @@ -391,6 +392,7 @@ "version": "7.26.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", @@ -2153,6 +2155,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2175,6 +2178,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2284,6 +2288,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -2705,6 +2710,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3592,6 +3598,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4473,6 +4480,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", "dev": true, + "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -4859,6 +4867,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5443,6 +5452,7 @@ "version": "18.2.38", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.38.tgz", "integrity": "sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5787,6 +5797,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5867,6 +5878,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5912,6 +5924,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.40.1.tgz", "integrity": "sha512-iUNxcXUNg9085TJx0HJLjqtDE0r1RZ0GOGrt8KNQqQT5ugu8lZsHuMUYW/e0lHhq6xBvmktU9Bw4CXP9VQeKrg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.6.1", "@algolia/client-abtesting": "5.40.1", @@ -6427,6 +6440,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -6744,6 +6758,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -7477,6 +7492,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7794,6 +7810,7 @@ "version": "3.32.0", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz", "integrity": "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ==", + "peer": true, "engines": { "node": ">=0.10" } @@ -8179,6 +8196,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -9334,6 +9352,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14047,6 +14066,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -14658,6 +14678,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15561,6 +15582,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -16359,6 +16381,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16368,6 +16391,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16420,6 +16444,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -16470,6 +16495,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -18051,6 +18077,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -18331,20 +18358,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ua-parser-js": { "version": "1.0.41", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", @@ -18720,6 +18733,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -19005,6 +19019,7 @@ "version": "5.95.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -19274,6 +19289,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", From 7090f519bfe881d897e0ac0ae1bf7c2534826064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:24:32 +0100 Subject: [PATCH 18/39] docs: Improve documentation based on feedback - Add Relic framework context in introduction with link - Clarify Session vs ContextProperty distinction - Emphasize request ID as purely request-scoped (not session data) - Reorder use cases to prioritize request ID tracking - Add note about when tenant should/shouldn't be in session - Elevate error handling from info box to dedicated subsection - Change from h3 to h2 section under Middleware - Add comprehensive explanation of exception handling - List common exceptions and their HTTP status codes - Explain fallback behavior when exceptions escape middleware - Simplify error handling info box to reference main section --- docs/06-concepts/18-webserver.md | 48 +++++++++++++++++--------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 784cab0b..4a909427 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -7,6 +7,8 @@ share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with 3rd party services. +Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. + :::caution Serverpod's web server is still experimental, and the APIs may change in the @@ -205,17 +207,9 @@ Method.post}); // Register the route pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` -:::info Error Handling The examples in this documentation omit error handling -for brevity. In production code, you should handle potential exceptions from -`jsonDecode()`, database operations, and file operations. - -**Recommended approach**: Use error handling middleware (see the Middleware -section below) to catch exceptions globally rather than adding try-catch blocks -to every route handler. This centralizes error handling and ensures consistent -error responses across your API. - -If an exception escapes all handlers and middleware, Serverpod will -automatically return a **500 Internal Server Error** response. ::: +:::info The examples in this documentation omit error handling for brevity. See +the Error Handling section in Middleware below for the recommended approach +using global error handling middleware. ::: ### HTTP methods @@ -548,10 +542,13 @@ Authorization'); }), ); } pod.webServer.addMiddleware(corsMiddleware, '/api'); ``` -### Error handling middleware +### Error handling + +Production applications need robust error handling. Rather than adding try-catch blocks to every route, use error handling middleware to catch exceptions globally and return consistent error responses. -A global error handling middleware can catch exceptions from routes and return -appropriate error responses: +#### Error handling middleware + +Error handling middleware wraps all your routes and catches any exceptions they throw: ```dart Handler errorHandlingMiddleware(Handler innerHandler) { return (Request request) async { try { return await innerHandler(request); } on FormatException @@ -569,8 +566,13 @@ print(stackTrace); '/'); ``` With error handling middleware in place, your route handlers can focus on -business logic without extensive try-catch blocks. Uncaught exceptions will be -caught and converted to appropriate HTTP responses. +business logic without extensive try-catch blocks. The middleware catches common exceptions like: + +- `FormatException` from `jsonDecode()` - Returns 400 Bad Request +- Database errors - Returns 500 Internal Server Error with logging +- Any other uncaught exceptions - Returns 500 with error details logged + +If an exception escapes all middleware, Serverpod will automatically return a 500 Internal Server Error response. However, using error handling middleware gives you control over error responses and logging. ### Middleware execution order @@ -609,7 +611,8 @@ per-request state. :::info Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard -Session. ::: +Session, such as request IDs, feature flags, or API version information +extracted from headers. ::: #### Why use ContextProperty? @@ -618,12 +621,13 @@ Since `Request` objects are immutable, you can't modify them directly. can be accessed by all downstream middleware and handlers. Common use cases include: -- **Request ID tracking** - Add correlation IDs for logging and tracing -- **Tenant identification** - Multi-tenant application context from - subdomains/headers -- **Feature flags** - Request-specific feature toggles based on headers/cookies -- **Rate limiting** - Store rate limit state per request +- **Request ID tracking** - Generated correlation IDs for distributed tracing + (purely request-scoped, not session data) - **API versioning** - Extract and store API version from headers +- **Feature flags** - Request-specific toggles based on headers or A/B testing +- **Rate limiting** - Per-request rate limit state +- **Tenant identification** - Multi-tenant context from subdomains (when not + part of user session) #### Creating a ContextProperty From 600201b9a3b56b0d8d60050b89952edcb7d7c6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:26:47 +0100 Subject: [PATCH 19/39] docs: Fix style issues in introduction - Replace weak intensifier 'very easy' with 'simple' - Hyphenate 'third-party' as compound adjective - Keep 'gives you access to' for clarity over 'provides' --- docs/06-concepts/18-webserver.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 4a909427..3cecbaa3 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -2,10 +2,10 @@ In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the -same way you would from a method call from an app. This makes it very easy to +same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to -communicate with 3rd party services. +communicate with third-party services. Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. From 377342b8d200dfee999ee56bfbd8c1f2751f2dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:41:52 +0100 Subject: [PATCH 20/39] docs: Fix code formatting broken by markdown rewrap Restored from commit before rewrap (aefd54c) and reapplied content changes: - Relic framework context with link - Style improvements (simple, third-party) - Error handling elevation and clarification - Session vs ContextProperty distinction improvements Code blocks now properly formatted with correct indentation. --- docs/06-concepts/18-webserver.md | 1704 +++++++++++++++++++----------- 1 file changed, 1066 insertions(+), 638 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 3cecbaa3..e3d98521 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -1,79 +1,63 @@ # Web server -In addition to the application server, Serverpod comes with a built-in web -server. The web server allows you to access your database and business layer the -same way you would from a method call from an app. This makes it simple to -share data for applications that need both an app and traditional web pages. You -can also use the web server to create webhooks or generate custom REST APIs to -communicate with third-party services. +In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with third-party services. Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. :::caution -Serverpod's web server is still experimental, and the APIs may change in the -future. This documentation should give you some hints on getting started, but we -plan to add more extensive documentation as the web server matures. +Serverpod's web server is still experimental, and the APIs may change in the future. This documentation should give you some hints on getting started, but we plan to add more extensive documentation as the web server matures. ::: -When you create a new Serverpod project, it sets up a web server by default. -When working with the web server, there are two main classes to understand; -`WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point -for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders -a web page using templates, while other `WebWidget` types can render JSON or -other custom responses. +When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders a web page using templates, while other `WebWidget` types can render JSON or other custom responses. ## Creating new routes and widgets -To add new pages to your web server, you add new routes. Typically, you do this -in your server.dart file before you start the Serverpod. By default, Serverpod -comes with a `RootRoute` and a static directory. +To add new pages to your web server, you add new routes. Typically, you do this in your server.dart file before you start the Serverpod. By default, Serverpod comes with a `RootRoute` and a static directory. -When receiving a web request, Serverpod will search and match the routes in the -order they were added. You can end a route's path with an asterisk (`*`) to -match all paths with the same beginning. +When receiving a web request, Serverpod will search and match the routes in the order they were added. You can end a route's path with an asterisk (`*`) to match all paths with the same beginning. -```dart // Add a single page. pod.webServer.addRoute(MyRoute(), -'/my/page/address'); +```dart +// Add a single page. +pod.webServer.addRoute(MyRoute(), '/my/page/address'); -// Match all paths that start with /item/ pod.webServer.addRoute(AnotherRoute(), -'/item/*'); ``` +// Match all paths that start with /item/ +pod.webServer.addRoute(AnotherRoute(), '/item/*'); +``` -Typically, you want to create custom routes for your pages. Do this by -overriding the WidgetRoute class and implementing the build method. +Typically, you want to create custom routes for your pages. Do this by overriding the WidgetRoute class and implementing the build method. -```dart class MyRoute extends WidgetRoute { @override Future -build(Session session, Request request) async { return MyPageWidget(title: 'Home -page'); } } ``` +```dart +class MyRoute extends WidgetRoute { + @override + Future build(Session session, Request request) async { + return MyPageWidget(title: 'Home page'); + } +} +``` -Your route's build method returns a `WebWidget`. The `TemplateWidget` consists -of an HTML template file and a corresponding Dart class. Create a new custom -widget by extending the `TemplateWidget` class. Then add a corresponding HTML -template and place it in the `web/templates` directory. The HTML file uses the -[Mustache](https://mustache.github.io/) template language. You set your template -parameters by updating the `values` field of your widget class. The values are -converted to `String` objects before being passed to the template. This makes it -possible to nest widgets, similarly to how widgets work in Flutter. +Your route's build method returns a `WebWidget`. The `TemplateWidget` consists of an HTML template file and a corresponding Dart class. Create a new custom widget by extending the `TemplateWidget` class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your widget class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest widgets, similarly to how widgets work in Flutter. -```dart class MyPageWidget extends TemplateWidget { MyPageWidget({required -String title}) : super(name: 'my_page') { values = { 'title': title, }; } } ``` +```dart +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = { + 'title': title, + }; + } +} +``` :::info -In the future, we plan to add a widget library to Serverpod with widgets -corresponding to the standard widgets used by Flutter, such as Column, Row, -Padding, Container, etc. This would make it possible to render server-side -widgets with similar code used within Flutter. +In the future, we plan to add a widget library to Serverpod with widgets corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side widgets with similar code used within Flutter. ::: ## Special widgets and routes -While `WidgetRoute` is great for custom HTML pages, Serverpod provides several -built-in widgets and routes for common use cases. These special types -automatically handle HTTP status codes and content types, so you don't need to -configure them manually. +While `WidgetRoute` is great for custom HTML pages, Serverpod provides several built-in widgets and routes for common use cases. These special types automatically handle HTTP status codes and content types, so you don't need to configure them manually. **Built-in widgets:** - `ListWidget` - Concatenates multiple widgets into a single response @@ -82,53 +66,66 @@ configure them manually. **Serving static files:** -Static assets like images, CSS, and JavaScript files are essential for web -applications. The `StaticRoute.directory()` method makes it easy to serve entire -directories with automatic content-type detection for common file formats. +Static assets like images, CSS, and JavaScript files are essential for web applications. The `StaticRoute.directory()` method makes it easy to serve entire directories with automatic content-type detection for common file formats. ### Static file cache-busting -When deploying static assets, browsers and CDNs (like CloudFront) cache files -aggressively for performance. This means updated files may not be served to -users unless you implement a cache-busting strategy. +When deploying static assets, browsers and CDNs (like CloudFront) cache files aggressively for performance. This means updated files may not be served to users unless you implement a cache-busting strategy. -Serverpod provides `CacheBustingConfig` to automatically version your static -files: +Serverpod provides `CacheBustingConfig` to automatically version your static files: -```dart final staticDir = Directory('web/static'); +```dart +final staticDir = Directory('web/static'); -final cacheBustingConfig = CacheBustingConfig( mountPrefix: '/static', -fileSystemRoot: staticDir, separator: '@', // or use custom separator like '___' +final cacheBustingConfig = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticDir, + separator: '@', // or use custom separator like '___' ); -pod.webServer.addRoute( StaticRoute.directory( staticDir, cacheBustingConfig: -cacheBustingConfig, cacheControlFactory: StaticRoute.publicImmutable(maxAge: -31536000), ), '/static/**', ); ``` +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), + ), + '/static/**', +); +``` **Generating versioned URLs:** Use the `assetPath()` method to generate cache-busted URLs for your assets: -```dart // In your route handler final imageUrl = await -cacheBustingConfig.assetPath('/static/logo.png'); // Returns: -/static/logo@.png +```dart +// In your route handler +final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); +// Returns: /static/logo@.png -// Pass to your template return MyPageWidget(logoUrl: imageUrl); ``` +// Pass to your template +return MyPageWidget(logoUrl: imageUrl); +``` The cache-busting system: - Automatically generates content-based hashes for asset versioning - Allows custom separators (default `@`, but you can use `___` or any other) - Preserves file extensions -- Works transparently - requesting `/static/logo@abc123.png` serves - `/static/logo.png` +- Works transparently - requesting `/static/logo@abc123.png` serves `/static/logo.png` **Combining with cache control:** For optimal performance, combine cache-busting with aggressive caching: -```dart pod.webServer.addRoute( StaticRoute.directory( staticDir, -cacheBustingConfig: cacheBustingConfig, cacheControlFactory: -StaticRoute.publicImmutable(maxAge: 31536000), // 1 year ), '/static/**', ); ``` +```dart +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year + ), + '/static/**', +); +``` This approach ensures: - Browsers cache files for a long time (better performance) @@ -137,84 +134,91 @@ This approach ensures: ### Conditional requests (ETags and Last-Modified) -`StaticRoute` automatically supports HTTP conditional requests through Relic's -`StaticHandler`. This provides efficient caching without transferring file -content when unchanged: +`StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file content when unchanged: **Supported features:** - **ETag headers** - Content-based fingerprinting for cache validation - **Last-Modified headers** - Timestamp-based cache validation -- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if - unchanged -- **If-Modified-Since** - Client sends timestamp, server returns 304 if not - modified +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not modified These work automatically without configuration: -```dart // Client first request: // GET /static/logo.png // Response: 200 OK // -ETag: "abc123" // Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT // -Content-Length: 12345 // [file content] - -// Client subsequent request: // GET /static/logo.png // If-None-Match: "abc123" -// Response: 304 Not Modified // ETag: "abc123" // [no body - saves bandwidth] +```dart +// Client first request: +// GET /static/logo.png +// Response: 200 OK +// ETag: "abc123" +// Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT +// Content-Length: 12345 +// [file content] + +// Client subsequent request: +// GET /static/logo.png +// If-None-Match: "abc123" +// Response: 304 Not Modified +// ETag: "abc123" +// [no body - saves bandwidth] ``` -When combined with cache-busting, conditional requests provide a fallback -validation mechanism even for cached assets, ensuring efficient delivery while -maintaining correctness. +When combined with cache-busting, conditional requests provide a fallback validation mechanism even for cached assets, ensuring efficient delivery while maintaining correctness. ## Database access and logging -The web server passes a `Session` object to the `WidgetRoute` class' `build` -method. This gives you access to all the features you typically get from a -standard method call to an endpoint. Use the database, logging, or caching the -same way you would in a method call. +The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. ## Advanced routing -The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern -web applications often need more flexibility. Whether you're building REST APIs, -handling file uploads, or creating webhooks for third-party integrations, -Serverpod's routing system provides the tools you need. +The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern web applications often need more flexibility. Whether you're building REST APIs, handling file uploads, or creating webhooks for third-party integrations, Serverpod's routing system provides the tools you need. -This section explores advanced routing patterns including custom route handlers, -HTTP method handling, path parameters, wildcards, and modular route -organization. These patterns give you fine-grained control over how your web -server processes requests and generates responses. +This section explores advanced routing patterns including custom route handlers, HTTP method handling, path parameters, wildcards, and modular route organization. These patterns give you fine-grained control over how your web server processes requests and generates responses. ### Custom Route classes -While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base -class gives you complete control over request handling. By extending `Route` and -implementing `handleCall()`, you can build REST APIs, serve files, or handle any -custom HTTP interaction. This is ideal when you need to work directly with -request bodies, headers, and response formats. +While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base class gives you complete control over request handling. By extending `Route` and implementing `handleCall()`, you can build REST APIs, serve files, or handle any custom HTTP interaction. This is ideal when you need to work directly with request bodies, headers, and response formats. -```dart class ApiRoute extends Route { ApiRoute() : super(methods: {Method.get, -Method.post}); +```dart +class ApiRoute extends Route { + ApiRoute() : super(methods: {Method.get, Method.post}); - @override Future handleCall(Session session, Request request) async { - // Access request method if (request.method == Method.post) { // Read request - body final body = await request.readAsString(); final data = jsonDecode(body); + @override + Future handleCall(Session session, Request request) async { + // Access request method + if (request.method == Method.post) { + // Read request body + final body = await request.readAsString(); + final data = jsonDecode(body); - // Process and return JSON response return Response.ok( body: - Body.fromString( jsonEncode({'status': 'success', 'data': data}), - mimeType: MimeType.json, ), ); } - - // Return data for GET requests return Response.ok( body: Body.fromString( - jsonEncode({'message': 'Hello from API'}), mimeType: MimeType.json, ), ); } + // Process and return JSON response + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'success', 'data': data}), + mimeType: MimeType.json, + ), + ); } + + // Return data for GET requests + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from API'}), + mimeType: MimeType.json, + ), + ); + } +} + +// Register the route +pod.webServer.addRoute(ApiRoute(), '/api/data'); +``` -// Register the route pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` - -:::info The examples in this documentation omit error handling for brevity. See -the Error Handling section in Middleware below for the recommended approach -using global error handling middleware. ::: +:::info +The examples in this documentation omit error handling for brevity. See the Error Handling section in Middleware below for the recommended approach using global error handling middleware. +::: ### HTTP methods -Routes can specify which HTTP methods they respond to using the `methods` -parameter. The available methods are: +Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: - `Method.get` - Retrieve data - `Method.post` - Create new resources @@ -224,196 +228,314 @@ parameter. The available methods are: - `Method.head` - Same as GET but without response body - `Method.options` - Query supported methods (used for CORS) -```dart class UserRoute extends Route { UserRoute() : super( methods: -{Method.get, Method.post, Method.delete}, ); - - @override Future handleCall(Session session, Request request) async { - switch (request.method) { case Method.get: return await _getUser(request); - case Method.post: return await _createUser(request); case Method.delete: - return await _deleteUser(request); default: return - Response.methodNotAllowed(); } } } ``` +```dart +class UserRoute extends Route { + UserRoute() : super( + methods: {Method.get, Method.post, Method.delete}, + ); + + @override + Future handleCall(Session session, Request request) async { + switch (request.method) { + case Method.get: + return await _getUser(request); + case Method.post: + return await _createUser(request); + case Method.delete: + return await _deleteUser(request); + default: + return Response.methodNotAllowed(); + } + } +} +``` ### Path parameters -Routes support named path parameters using the `:paramName` syntax. These are -automatically extracted and made available through the `Request` object: +Routes support named path parameters using the `:paramName` syntax. These are automatically extracted and made available through the `Request` object: -```dart class UserRoute extends Route { UserRoute() : super(methods: -{Method.get}); +```dart +class UserRoute extends Route { + UserRoute() : super(methods: {Method.get}); - @override void injectIn(RelicRouter router) { // Define route with path - parameter router.get('/:id', asHandler); } + @override + void injectIn(RelicRouter router) { + // Define route with path parameter + router.get('/:id', asHandler); + } - @override Future handleCall(Session session, Request request) async { - // Extract path parameter using symbol final id = request.pathParameters[#id]; + @override + Future handleCall(Session session, Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; - if (id == null) { return Response.badRequest( body: Body.fromString('Missing - user ID'), ); } + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } - final userId = int.tryParse(id); if (userId == null) { return - Response.badRequest( body: Body.fromString('Invalid user ID'), ); } + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } final user = await User.db.findById(session, userId); - if (user == null) { return Response.notFound(); } + if (user == null) { + return Response.notFound(); + } - return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), - mimeType: MimeType.json, ), ); } } + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } +} // Register at /api/users - will match /api/users/123 -pod.webServer.addRoute(UserRoute(), '/api/users'); ``` +pod.webServer.addRoute(UserRoute(), '/api/users'); +``` You can use multiple path parameters in a single route: -```dart router.get('/:userId/posts/:postId', handler); // Matches: -/123/posts/456 // request.pathParameters[#userId] => '123' // -request.pathParameters[#postId] => '456' ``` +```dart +router.get('/:userId/posts/:postId', handler); +// Matches: /123/posts/456 +// request.pathParameters[#userId] => '123' +// request.pathParameters[#postId] => '456' +``` ### Wildcards Routes also support wildcard matching for catching all paths: -```dart // Single-level wildcard - matches /item/foo but not /item/foo/bar +```dart +// Single-level wildcard - matches /item/foo but not /item/foo/bar pod.webServer.addRoute(ItemRoute(), '/item/*'); // Tail-match wildcard - matches /item/foo and /item/foo/bar/baz -pod.webServer.addRoute(ItemRoute(), '/item/**'); ``` +pod.webServer.addRoute(ItemRoute(), '/item/**'); +``` -:::info Performance Guarantee The `/**` wildcard is a **tail-match** pattern and -can only appear at the end of a route path (e.g., `/static/**`). Patterns like -`/a/**/b` are not supported. This design ensures O(h) route lookup performance, -where h is the path length, without backtracking. This keeps routing fast and -predictable, even with many routes. ::: +:::info Performance Guarantee +The `/**` wildcard is a **tail-match** pattern and can only appear at the end of a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. This design ensures O(h) route lookup performance, where h is the path length, without backtracking. This keeps routing fast and predictable, even with many routes. +::: Access the matched path information through the `Request` object: -```dart @override Future handleCall(Session session, Request request) -async { // Get the remaining path after the route prefix final remainingPath = -request.url.path; +```dart +@override +Future handleCall(Session session, Request request) async { + // Get the remaining path after the route prefix + final remainingPath = request.url.path; - // Access query parameters final query = request.url.queryParameters['query']; + // Access query parameters + final query = request.url.queryParameters['query']; - return Response.ok( body: Body.fromString('Path: $remainingPath, Query: - $query'), ); } ``` + return Response.ok( + body: Body.fromString('Path: $remainingPath, Query: $query'), + ); +} +``` ### Fallback routes You can set a fallback route that handles requests when no other route matches: -```dart class NotFoundRoute extends Route { @override Future -handleCall(Session session, Request request) async { return Response.notFound( -body: Body.fromString('Page not found: ${request.url.path}'), ); } } - -// Set as fallback pod.webServer.fallbackRoute = NotFoundRoute(); ``` +```dart +class NotFoundRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.notFound( + body: Body.fromString('Page not found: ${request.url.path}'), + ); + } +} + +// Set as fallback +pod.webServer.fallbackRoute = NotFoundRoute(); +``` ### Modular routes with injectIn -As your web server grows, managing dozens of individual route registrations can -become unwieldy. Modular routes solve this by letting you group related -endpoints into reusable modules. For example, you might create a -`UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST -/users`, `PUT /users/:id`, etc.) in a single cohesive unit. +As your web server grows, managing dozens of individual route registrations can become unwieldy. Modular routes solve this by letting you group related endpoints into reusable modules. For example, you might create a `UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. -The key to modular routes is the `injectIn()` method. When you call -`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` -on a router group for that path. By overriding `injectIn()`, you can register -multiple handler functions instead of implementing a single `handleCall()` -method. This pattern is perfect for REST resources, API modules, or any group of -related endpoints. +The key to modular routes is the `injectIn()` method. When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for that path. By overriding `injectIn()`, you can register multiple handler functions instead of implementing a single `handleCall()` method. This pattern is perfect for REST resources, API modules, or any group of related endpoints. -:::info Session Access in Modular Routes When using `injectIn()` with handler -functions (`router.get('/', _handler)`), your handlers receive only a `Request` -parameter. To access the `Session`, use `request.session`: +:::info Session Access in Modular Routes +When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use `request.session`: -```dart Future _handler(Request request) async { final session = -request.session; // Extract Session from Request // ... use session } ``` +```dart +Future _handler(Request request) async { + final session = request.session; // Extract Session from Request + // ... use session +} +``` -This differs from `Route.handleCall()` which receives both Session and Request -as explicit parameters. The modular route pattern uses Relic's router directly, -which only provides Request to handlers. ::: +This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. +::: #### Creating a CRUD module -Here's an example of a modular CRUD route that registers multiple endpoints with -path parameters: - -```dart class UserCrudModule extends Route { @override void injectIn(RelicRouter -router) { // Register multiple routes with path parameters router ..get('/', -_list) ..get('/:id', _get) ..post('/', _create) ..put('/:id', _update) -..delete('/:id', _delete); } - - // Handler methods Future _list(Request request) async { final session - = request.session; final users = await User.db.find(session); - - return Response.ok( body: Body.fromString( jsonEncode(users.map((u) => - u.toJson()).toList()), mimeType: MimeType.json, ), ); } - - Future _get(Request request) async { // Extract path parameter using - symbol final id = request.pathParameters[#id]; if (id == null) { return - Response.badRequest( body: Body.fromString('Missing user ID'), ); } - - final userId = int.tryParse(id); if (userId == null) { return - Response.badRequest( body: Body.fromString('Invalid user ID'), ); } +Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get) + ..post('/', _create) + ..put('/:id', _update) + ..delete('/:id', _delete); + } + + // Handler methods + Future _list(Request request) async { + final session = request.session; + final users = await User.db.find(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } + + Future _get(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } - final session = request.session; final user = await - User.db.findById(session, userId); + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } - if (user == null) { return Response.notFound( body: Body.fromString('User - not found'), ); } + final session = request.session; + final user = await User.db.findById(session, userId); - return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), - mimeType: MimeType.json, ), ); } - - Future _create(Request request) async { final body = await - request.readAsString(); final data = jsonDecode(body); final session = - request.session; + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } - final user = User(name: data['name'], email: data['email']); await - User.db.insertRow(session, user); + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _create(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; - return Response.created( body: Body.fromString( jsonEncode(user.toJson()), - mimeType: MimeType.json, ), ); } - - Future _update(Request request) async { // Extract path parameter - using symbol final id = request.pathParameters[#id]; if (id == null) { return - Response.badRequest( body: Body.fromString('Missing user ID'), ); } + final user = User(name: data['name'], email: data['email']); + await User.db.insertRow(session, user); + + return Response.created( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _update(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } - final userId = int.tryParse(id); if (userId == null) { return - Response.badRequest( body: Body.fromString('Invalid user ID'), ); } + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } - final body = await request.readAsString(); final data = jsonDecode(body); + final body = await request.readAsString(); + final data = jsonDecode(body); final session = request.session; - final user = await User.db.findById(session, userId); if (user == null) { - return Response.notFound( body: Body.fromString('User not found'), ); } - - user.name = data['name'] ?? user.name; user.email = data['email'] ?? - user.email; await User.db.updateRow(session, user); + final user = await User.db.findById(session, userId); + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } - return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), - mimeType: MimeType.json, ), ); } - - Future _delete(Request request) async { // Extract path parameter - using symbol final id = request.pathParameters[#id]; if (id == null) { return - Response.badRequest( body: Body.fromString('Missing user ID'), ); } + user.name = data['name'] ?? user.name; + user.email = data['email'] ?? user.email; + await User.db.updateRow(session, user); + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _delete(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } - final userId = int.tryParse(id); if (userId == null) { return - Response.badRequest( body: Body.fromString('Invalid user ID'), ); } + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } - final session = request.session; final deleted = await - User.db.deleteRow(session, userId); + final session = request.session; + final deleted = await User.db.deleteRow(session, userId); - if (!deleted) { return Response.notFound( body: Body.fromString('User not - found'), ); } + if (!deleted) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } - return Response.noContent(); } + return Response.noContent(); + } - // Required by Route but not used since we override injectIn @override - Future handleCall(Session session, Request request) async { throw - UnimplementedError('This route uses injectIn'); } } + // Required by Route but not used since we override injectIn + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} // Register the entire CRUD module under /api/users -pod.webServer.addRoute(UserCrudModule(), '/api/users'); ``` +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` This creates the following RESTful endpoints: - `GET /api/users` - List all users @@ -422,125 +544,163 @@ This creates the following RESTful endpoints: - `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) - `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) -:::tip Path parameters are accessed using symbols: -`request.pathParameters[#paramName]`. Always validate and parse these values -since they come from user input as strings. ::: +:::tip +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. Always validate and parse these values since they come from user input as strings. +::: #### Composing multiple modules You can create a parent module that composes multiple sub-modules: -```dart class ApiModule extends Route { @override void injectIn(RelicRouter -router) { // Inject sub-modules at different paths -router.group('/users').inject(UserCrudModule()); -router.group('/posts').inject(PostCrudModule()); -router.group('/comments').inject(CommentCrudModule()); - - // Add module-level routes router.get('/health', _healthCheck); } - - Future _healthCheck(Request request) async { return Response.ok( body: - Body.fromString( jsonEncode({'status': 'healthy', 'timestamp': - DateTime.now().toIso8601String()}), mimeType: MimeType.json, ), ); } - - @override Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); } } - -// Register the entire API module pod.webServer.addRoute(ApiModule(), '/api'); +```dart +class ApiModule extends Route { + @override + void injectIn(RelicRouter router) { + // Inject sub-modules at different paths + router.group('/users').inject(UserCrudModule()); + router.group('/posts').inject(PostCrudModule()); + router.group('/comments').inject(CommentCrudModule()); + + // Add module-level routes + router.get('/health', _healthCheck); + } + + Future _healthCheck(Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), + mimeType: MimeType.json, + ), + ); + } + + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire API module +pod.webServer.addRoute(ApiModule(), '/api'); ``` This pattern enables you to: - **Organize routes hierarchically** - Group related functionality together - **Reuse route modules** - Use the same module in different applications - **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler - implementation +- **Separate concerns** - Keep route registration logic separate from handler implementation -:::tip When overriding `injectIn`, you typically don't need to implement -`handleCall` since you're registering handler functions directly with the -router. You can throw `UnimplementedError` in `handleCall` to make it clear the -method isn't used. ::: +:::tip +When overriding `injectIn`, you typically don't need to implement `handleCall` since you're registering handler functions directly with the router. You can throw `UnimplementedError` in `handleCall` to make it clear the method isn't used. +::: ## Middleware -Routes handle the core logic of your application, but many concerns cut across -multiple routes: logging every request, validating API keys, handling CORS -headers, or catching errors. Rather than duplicating this code in each route, -middleware lets you apply it globally or to specific path prefixes. +Routes handle the core logic of your application, but many concerns cut across multiple routes: logging every request, validating API keys, handling CORS headers, or catching errors. Rather than duplicating this code in each route, middleware lets you apply it globally or to specific path prefixes. -Middleware functions are wrappers that sit between the incoming request and your -route handler. They can inspect or modify requests before they reach your -routes, and transform responses before they're sent back to the client. This -makes middleware perfect for authentication, logging, error handling, and any -other cross-cutting concern in your web server. +Middleware functions are wrappers that sit between the incoming request and your route handler. They can inspect or modify requests before they reach your routes, and transform responses before they're sent back to the client. This makes middleware perfect for authentication, logging, error handling, and any other cross-cutting concern in your web server. ### Adding middleware Use the `addMiddleware` method to apply middleware to specific path prefixes: -```dart // Apply to all routes pod.webServer.addMiddleware(loggingMiddleware, -'/'); +```dart +// Apply to all routes +pod.webServer.addMiddleware(loggingMiddleware, '/'); -// Apply only to API routes pod.webServer.addMiddleware(authMiddleware, '/api'); +// Apply only to API routes +pod.webServer.addMiddleware(authMiddleware, '/api'); ``` ### Creating custom middleware -Middleware is a function that takes a `Handler` and returns a new `Handler`. -Here's a simple logging middleware example: +Middleware is a function that takes a `Handler` and returns a new `Handler`. Here's a simple logging middleware example: -```dart Handler loggingMiddleware(Handler innerHandler) { return (Request -request) async { final start = DateTime.now(); print('→ -${request.method.name.toUpperCase()} ${request.url.path}'); +```dart +Handler loggingMiddleware(Handler innerHandler) { + return (Request request) async { + final start = DateTime.now(); + print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); - // Call the next handler in the chain final response = await - innerHandler(request); + // Call the next handler in the chain + final response = await innerHandler(request); - final duration = DateTime.now().difference(start); print('← - ${response.statusCode} (${duration.inMilliseconds}ms)'); + final duration = DateTime.now().difference(start); + print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); - return response; }; } ``` + return response; + }; +} +``` ### API key validation middleware A common use case is validating API keys for protected routes: -```dart Handler apiKeyMiddleware(Handler innerHandler) { return (Request -request) async { // Check for API key in header final apiKey = -request.headers['X-API-Key']?.firstOrNull; +```dart +Handler apiKeyMiddleware(Handler innerHandler) { + return (Request request) async { + // Check for API key in header + final apiKey = request.headers['X-API-Key']?.firstOrNull; - if (apiKey == null) { return Response.unauthorized( body: - Body.fromString('API key required'), ); } + if (apiKey == null) { + return Response.unauthorized( + body: Body.fromString('API key required'), + ); + } - // Verify API key if (!await isValidApiKey(apiKey)) { return - Response.forbidden( body: Body.fromString('Invalid API key'), ); } + // Verify API key + if (!await isValidApiKey(apiKey)) { + return Response.forbidden( + body: Body.fromString('Invalid API key'), + ); + } - // Continue to the next handler return await innerHandler(request); }; } + // Continue to the next handler + return await innerHandler(request); + }; +} -// Apply to protected routes pod.webServer.addMiddleware(apiKeyMiddleware, -'/api'); ``` +// Apply to protected routes +pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); +``` -:::info For user authentication, use Serverpod's built-in authentication system -which integrates with the `Session` object. The middleware examples here are for -additional web-specific validations like API keys, rate limiting, or request -validation. ::: +:::info +For user authentication, use Serverpod's built-in authentication system which integrates with the `Session` object. The middleware examples here are for additional web-specific validations like API keys, rate limiting, or request validation. +::: ### CORS middleware Enable Cross-Origin Resource Sharing for your API: -```dart Handler corsMiddleware(Handler innerHandler) { return (Request request) -async { // Handle preflight requests if (request.method == Method.options) { -return Response.ok( headers: Headers.build((h) { -h.set('Access-Control-Allow-Origin', '*'); h.set('Access-Control-Allow-Methods', -'GET, POST, PUT, DELETE'); h.set('Access-Control-Allow-Headers', 'Content-Type, -Authorization'); }), ); } +```dart +Handler corsMiddleware(Handler innerHandler) { + return (Request request) async { + // Handle preflight requests + if (request.method == Method.options) { + return Response.ok( + headers: Headers.build((h) { + h.set('Access-Control-Allow-Origin', '*'); + h.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); + h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + }), + ); + } - // Process the request final response = await innerHandler(request); + // Process the request + final response = await innerHandler(request); - // Add CORS headers to response return response.change( headers: - Headers.build((h) { h.set('Access-Control-Allow-Origin', '*'); }), ); }; } + // Add CORS headers to response + return response.change( + headers: Headers.build((h) { + h.set('Access-Control-Allow-Origin', '*'); + }), + ); + }; +} -pod.webServer.addMiddleware(corsMiddleware, '/api'); ``` +pod.webServer.addMiddleware(corsMiddleware, '/api'); +``` ### Error handling @@ -550,23 +710,40 @@ Production applications need robust error handling. Rather than adding try-catch Error handling middleware wraps all your routes and catches any exceptions they throw: -```dart Handler errorHandlingMiddleware(Handler innerHandler) { return (Request -request) async { try { return await innerHandler(request); } on FormatException -catch (e) { // Handle JSON parsing errors return Response.badRequest( body: -Body.fromString( jsonEncode({'error': 'Invalid request format', 'message': -e.message}), mimeType: MimeType.json, ), ); } catch (e, stackTrace) { // Log the -error print('Error handling ${request.method} ${request.url.path}: $e'); -print(stackTrace); +```dart +Handler errorHandlingMiddleware(Handler innerHandler) { + return (Request request) async { + try { + return await innerHandler(request); + } on FormatException catch (e) { + // Handle JSON parsing errors + return Response.badRequest( + body: Body.fromString( + jsonEncode({'error': 'Invalid request format', 'message': e.message}), + mimeType: MimeType.json, + ), + ); + } catch (e, stackTrace) { + // Log the error + print('Error handling ${request.method} ${request.url.path}: $e'); + print(stackTrace); - // Return a generic error response return Response.internalServerError( - body: Body.fromString( jsonEncode({'error': 'Internal server error'}), - mimeType: MimeType.json, ), ); } }; } + // Return a generic error response + return Response.internalServerError( + body: Body.fromString( + jsonEncode({'error': 'Internal server error'}), + mimeType: MimeType.json, + ), + ); + } + }; +} -// Apply to all routes pod.webServer.addMiddleware(errorHandlingMiddleware, -'/'); ``` +// Apply to all routes +pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); +``` -With error handling middleware in place, your route handlers can focus on -business logic without extensive try-catch blocks. The middleware catches common exceptions like: +With error handling middleware in place, your route handlers can focus on business logic without extensive try-catch blocks. The middleware catches common exceptions like: - `FormatException` from `jsonDecode()` - Returns 400 Bad Request - Database errors - Returns 500 Internal Server Error with logging @@ -576,14 +753,13 @@ If an exception escapes all middleware, Serverpod will automatically return a 50 ### Middleware execution order -Middleware is applied based on path hierarchy, with more specific paths taking -precedence. Within the same path, middleware executes in the order it was -registered: +Middleware is applied based on path hierarchy, with more specific paths taking precedence. Within the same path, middleware executes in the order it was registered: -```dart pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first -(outer) pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second -(inner) for /api routes pod.webServer.addMiddleware(rateLimitMiddleware, -'/api'); // Executes third (innermost) for /api routes ``` +```dart +pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) +pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes +pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes +``` For a request to `/api/users`, the execution order is: 1. `loggingMiddleware` (before) @@ -596,232 +772,295 @@ For a request to `/api/users`, the execution order is: ### Request-scoped data with ContextProperty -Middleware often needs to pass computed data to downstream handlers. For -example, a tenant identification middleware might extract the tenant ID from a -subdomain, or a logging middleware might generate a request ID for tracing. -Since `Request` objects are immutable, you can't just add properties to them. -This is where `ContextProperty` comes in. +Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a subdomain, or a logging middleware might generate a request ID for tracing. Since `Request` objects are immutable, you can't just add properties to them. This is where `ContextProperty` comes in. -`ContextProperty` provides a type-safe way to attach data to a `Request` -object without modifying it. Think of it as a side channel for request-scoped -data that middleware can write to and routes can read from. The data is -automatically cleaned up when the request completes, making it perfect for -per-request state. +`ContextProperty` provides a type-safe way to attach data to a `Request` object without modifying it. Think of it as a side channel for request-scoped data that middleware can write to and routes can read from. The data is automatically cleaned up when the request completes, making it perfect for per-request state. -:::info Note that Serverpod's `Route.handleCall()` already receives a `Session` -parameter which includes authenticated user information if available. Use -`ContextProperty` for web-specific request data that isn't part of the standard -Session, such as request IDs, feature flags, or API version information -extracted from headers. ::: +:::info +Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard Session, such as request IDs, feature flags, or API version information extracted from headers. +::: #### Why use ContextProperty? -Since `Request` objects are immutable, you can't modify them directly. -`ContextProperty` allows you to associate additional data with a request that -can be accessed by all downstream middleware and handlers. Common use cases -include: +Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that can be accessed by all downstream middleware and handlers. Common use cases include: -- **Request ID tracking** - Generated correlation IDs for distributed tracing - (purely request-scoped, not session data) +- **Request ID tracking** - Generated correlation IDs for distributed tracing (purely request-scoped, not session data) - **API versioning** - Extract and store API version from headers - **Feature flags** - Request-specific toggles based on headers or A/B testing - **Rate limiting** - Per-request rate limit state -- **Tenant identification** - Multi-tenant context from subdomains (when not - part of user session) +- **Tenant identification** - Multi-tenant context from subdomains (when not part of user session) #### Creating a ContextProperty Define a `ContextProperty` as a top-level constant or static field: -```dart // Define a property for request ID tracking final requestIdProperty = -ContextProperty(); +```dart +// Define a property for request ID tracking +final requestIdProperty = ContextProperty(); -// Define a property for tenant identification final tenantProperty = -ContextProperty(); +// Define a property for tenant identification +final tenantProperty = ContextProperty(); -// Optional: with a default value final featureFlagsProperty = -ContextProperty( defaultValue: () => FeatureFlags.defaults(), ); +// Optional: with a default value +final featureFlagsProperty = ContextProperty( + defaultValue: () => FeatureFlags.defaults(), +); ``` #### Setting values in middleware -Middleware can set values on the context property, making them available to all -downstream handlers: +Middleware can set values on the context property, making them available to all downstream handlers: -```dart final requestIdProperty = ContextProperty(); +```dart +final requestIdProperty = ContextProperty(); -Handler requestIdMiddleware(Handler innerHandler) { return (Request request) -async { // Generate a unique request ID for tracing final requestId = -Uuid().v4(); +Handler requestIdMiddleware(Handler innerHandler) { + return (Request request) async { + // Generate a unique request ID for tracing + final requestId = Uuid().v4(); - // Attach to request context requestIdProperty[request] = requestId; + // Attach to request context + requestIdProperty[request] = requestId; - // Log the incoming request print('[$requestId] ${request.method} - ${request.url.path}'); + // Log the incoming request + print('[$requestId] ${request.method} ${request.url.path}'); - // Continue to next handler final response = await innerHandler(request); + // Continue to next handler + final response = await innerHandler(request); - // Log the response print('[$requestId] Response: ${response.statusCode}'); + // Log the response + print('[$requestId] Response: ${response.statusCode}'); - // Optionally add request ID to response headers return response.change( - headers: {'X-Request-ID': requestId}, ); }; } ``` + // Optionally add request ID to response headers + return response.change( + headers: {'X-Request-ID': requestId}, + ); + }; +} +``` #### Accessing values in routes Route handlers can retrieve the value from the context property: -```dart class ApiRoute extends Route { @override Future -handleCall(Session session, Request request) async { // Get the request ID from -context final requestId = requestIdProperty[request]; - - // Use it for logging or tracing session.log('Processing request - $requestId'); - - // Your route logic here final data = await processRequest(session); - - return Response.ok( body: Body.fromString( jsonEncode(data), mimeType: - MimeType.json, ), ); } } ``` +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Get the request ID from context + final requestId = requestIdProperty[request]; + + // Use it for logging or tracing + session.log('Processing request $requestId'); + + // Your route logic here + final data = await processRequest(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(data), + mimeType: MimeType.json, + ), + ); + } +} +``` #### Safe access with getOrNull If a value might not be set, use `getOrNull()` to avoid exceptions: -```dart class TenantRoute extends Route { @override Future -handleCall(Session session, Request request) async { // Safely get tenant, -returns null if not set final tenant = tenantProperty.getOrNull(request); - - if (tenant != null) { // Fetch tenant-specific data final data = await - session.db.find(where: (t) => t.tenantId.equals(tenant)); return - Response.ok( body: Body.fromString(jsonEncode(data), mimeType: - MimeType.json), ); } else { return Response.badRequest( body: - Body.fromString('Missing tenant identifier'), ); } } } ``` +```dart +class TenantRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Safely get tenant, returns null if not set + final tenant = tenantProperty.getOrNull(request); + + if (tenant != null) { + // Fetch tenant-specific data + final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } else { + return Response.badRequest( + body: Body.fromString('Missing tenant identifier'), + ); + } + } +} +``` #### Complete multi-tenant example Here's a complete example showing tenant identification from subdomains: -```dart // Define the context property for tenant ID final tenantProperty = -ContextProperty(); +```dart +// Define the context property for tenant ID +final tenantProperty = ContextProperty(); -// Tenant identification middleware (extracts from subdomain) Handler -tenantMiddleware(Handler innerHandler) { return (Request request) async { final -host = request.headers.host; +// Tenant identification middleware (extracts from subdomain) +Handler tenantMiddleware(Handler innerHandler) { + return (Request request) async { + final host = request.headers.host; - if (host == null) { return Response.badRequest( body: - Body.fromString('Missing host header'), ); } + if (host == null) { + return Response.badRequest( + body: Body.fromString('Missing host header'), + ); + } - // Extract tenant from subdomain (e.g., acme.example.com -> "acme") final - parts = host.host.split('.'); if (parts.length < 2) { return - Response.badRequest( body: Body.fromString('Invalid hostname format'), ); } + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") + final parts = host.host.split('.'); + if (parts.length < 2) { + return Response.badRequest( + body: Body.fromString('Invalid hostname format'), + ); + } final tenant = parts.first; - // Validate tenant exists (implement your own logic) final session = - request.session; final tenantExists = await validateTenant(session, tenant); - - if (!tenantExists) { return Response.notFound( body: Body.fromString('Tenant - not found'), ); } - - // Attach tenant to context tenantProperty[request] = tenant; - - return await innerHandler(request); }; } - -// Usage in your server pod.webServer.addMiddleware(tenantMiddleware, '/'); - -// Routes automatically have access to the tenant class TenantDataRoute extends -Route { @override Future handleCall(Session session, Request request) -async { final tenant = tenantProperty[request]; + // Validate tenant exists (implement your own logic) + final session = request.session; + final tenantExists = await validateTenant(session, tenant); - // Fetch tenant-specific data final data = await session.db.find( - where: (p) => p.tenantId.equals(tenant), ); + if (!tenantExists) { + return Response.notFound( + body: Body.fromString('Tenant not found'), + ); + } - return Response.ok( body: Body.fromString( jsonEncode(data.map((p) => - p.toJson()).toList()), mimeType: MimeType.json, ), ); } } ``` + // Attach tenant to context + tenantProperty[request] = tenant; + + return await innerHandler(request); + }; +} + +// Usage in your server +pod.webServer.addMiddleware(tenantMiddleware, '/'); + +// Routes automatically have access to the tenant +class TenantDataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final tenant = tenantProperty[request]; + + // Fetch tenant-specific data + final data = await session.db.find( + where: (p) => p.tenantId.equals(tenant), + ); + + return Response.ok( + body: Body.fromString( + jsonEncode(data.map((p) => p.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } +} +``` #### Multiple context properties You can use multiple context properties for different types of data: -```dart final requestIdProperty = ContextProperty(); final -tenantProperty = ContextProperty(); final apiVersionProperty = -ContextProperty(); - -Handler requestContextMiddleware(Handler innerHandler) { return (Request -request) async { // Generate and attach request ID final requestId = -Uuid().v4(); requestIdProperty[request] = requestId; - - // Extract tenant from subdomain or header final host = - request.headers.host; if (host != null) { final tenant = - host.host.split('.').first; tenantProperty[request] = tenant; } +```dart +final requestIdProperty = ContextProperty(); +final tenantProperty = ContextProperty(); +final apiVersionProperty = ContextProperty(); + +Handler requestContextMiddleware(Handler innerHandler) { + return (Request request) async { + // Generate and attach request ID + final requestId = Uuid().v4(); + requestIdProperty[request] = requestId; + + // Extract tenant from subdomain or header + final host = request.headers.host; + if (host != null) { + final tenant = host.host.split('.').first; + tenantProperty[request] = tenant; + } - // Extract API version from header final apiVersion = - request.headers['X-API-Version']?.firstOrNull ?? '1.0'; + // Extract API version from header + final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; apiVersionProperty[request] = apiVersion; - return await innerHandler(request); }; } + return await innerHandler(request); + }; +} -// Later in your route class DataRoute extends Route { @override Future -handleCall(Session session, Request request) async { final requestId = -requestIdProperty[request]; final tenant = tenantProperty[request]; final -apiVersion = apiVersionProperty[request]; +// Later in your route +class DataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final requestId = requestIdProperty[request]; + final tenant = tenantProperty[request]; + final apiVersion = apiVersionProperty[request]; session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); - // Fetch tenant-specific data final data = await fetchTenantData(session, - tenant); + // Fetch tenant-specific data + final data = await fetchTenantData(session, tenant); - return Response.ok( body: Body.fromString(jsonEncode(data), mimeType: - MimeType.json), ); } } ``` + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } +} +``` :::tip Best Practices - Define `ContextProperty` instances as top-level constants or static fields -- Use descriptive names for your properties (e.g., `requestIdProperty`, not just - `requestId`) +- Use descriptive names for your properties (e.g., `requestIdProperty`, not just `requestId`) - Use `getOrNull()` when the value might not be set - Set properties in middleware, not in routes -- Use specific types for better type safety ::: +- Use specific types for better type safety +::: ### Built-in logging middleware -Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient -request logging: +Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient request logging: -```dart import 'package:serverpod/serverpod.dart'; +```dart +import 'package:serverpod/serverpod.dart'; -pod.webServer.addMiddleware(logRequests(), '/'); ``` +pod.webServer.addMiddleware(logRequests(), '/'); +``` This logs all requests with method, path, status code, and response time. ## Typed headers -HTTP headers are traditionally accessed as strings, which means you need to -manually parse values, handle edge cases, and validate formats. Serverpod's web -server (via Relic) provides a better approach: typed headers that automatically -parse header values into strongly-typed Dart objects. +HTTP headers are traditionally accessed as strings, which means you need to manually parse values, handle edge cases, and validate formats. Serverpod's web server (via Relic) provides a better approach: typed headers that automatically parse header values into strongly-typed Dart objects. -For example, instead of parsing `Authorization: Bearer abc123` as a string and -extracting the token yourself, you can access `request.headers.authorization` to -get a `BearerAuthorizationHeader` object with the token already parsed and -validated. This eliminates boilerplate code, catches errors early, and makes -your code more maintainable. +For example, instead of parsing `Authorization: Bearer abc123` as a string and extracting the token yourself, you can access `request.headers.authorization` to get a `BearerAuthorizationHeader` object with the token already parsed and validated. This eliminates boilerplate code, catches errors early, and makes your code more maintainable. ### Reading typed headers Access typed headers through extension methods on `request.headers`: -```dart class ApiRoute extends Route { @override Future -handleCall(Session session, Request request) async { // Type-safe accessors -return parsed objects or null final auth = request.headers.authorization; // -AuthorizationHeader? final contentType = request.headers.contentType; // -ContentTypeHeader? final cookies = request.headers.cookie; // CookieHeader? -final userAgent = request.headers.userAgent; // String? final host = -request.headers.host; // HostHeader? - - // Raw string access is also available for any header final authRaw = - request.headers['Authorization']; // Iterable? final custom = - request.headers['X-Custom-Header']; // Iterable? - - return Response.ok(); } } ``` +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Type-safe accessors return parsed objects or null + final auth = request.headers.authorization; // AuthorizationHeader? + final contentType = request.headers.contentType; // ContentTypeHeader? + final cookies = request.headers.cookie; // CookieHeader? + final userAgent = request.headers.userAgent; // String? + final host = request.headers.host; // HostHeader? + + // Raw string access is also available for any header + final authRaw = request.headers['Authorization']; // Iterable? + final custom = request.headers['X-Custom-Header']; // Iterable? + + return Response.ok(); + } +} +``` Common request headers include: - `authorization` - AuthorizationHeader (Bearer/Basic/Digest) @@ -840,12 +1079,24 @@ Common request headers include: Set typed headers in responses using the `Headers.build()` builder pattern: -```dart return Response.ok( headers: Headers.build((h) { h.cacheControl = -CacheControlHeader( maxAge: 3600, publicCache: true, ); h.contentType = -ContentTypeHeader( mimeType: MimeType.json, charset: 'utf-8', ); - - // Set custom headers (raw) h['X-API-Version'] = ['2.0']; }), body: - Body.fromString(jsonEncode(data)), ); ``` +```dart +return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, + publicCache: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + + // Set custom headers (raw) + h['X-API-Version'] = ['2.0']; + }), + body: Body.fromString(jsonEncode(data)), +); +``` Common response headers include: - `cacheControl` - CacheControlHeader @@ -859,38 +1110,66 @@ Common response headers include: The `AuthorizationHeader` supports three authentication schemes: -**Bearer Token (JWT, OAuth):** ```dart final auth = -request.headers.authorization; +**Bearer Token (JWT, OAuth):** +```dart +final auth = request.headers.authorization; -if (auth is BearerAuthorizationHeader) { final token = auth.token; // The actual -token string +if (auth is BearerAuthorizationHeader) { + final token = auth.token; // The actual token string - // Validate token if (!await validateToken(token)) { return - Response.unauthorized(); } } ``` + // Validate token + if (!await validateToken(token)) { + return Response.unauthorized(); + } +} +``` -**Basic Authentication:** ```dart if (auth is BasicAuthorizationHeader) { final -username = auth.username; final password = auth.password; +**Basic Authentication:** +```dart +if (auth is BasicAuthorizationHeader) { + final username = auth.username; + final password = auth.password; - // Validate credentials if (!await validateCredentials(username, password)) { - return Response.unauthorized(); } } ``` + // Validate credentials + if (!await validateCredentials(username, password)) { + return Response.unauthorized(); + } +} +``` -**Setting Bearer token:** ```dart headers: Headers.build((h) { h.authorization = -BearerAuthorizationHeader(token: 'eyJhbGc...'); }), ``` +**Setting Bearer token:** +```dart +headers: Headers.build((h) { + h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); +}), +``` ### CacheControlHeader - Cache directives Control caching behavior with type-safe cache directives: -```dart // Public cache with 1 hour expiration headers: Headers.build((h) { -h.cacheControl = CacheControlHeader( maxAge: 3600, // Cache for 1 hour -publicCache: true, // Shared cache allowed mustRevalidate: true, // Must -revalidate after expiry staleWhileRevalidate: 86400, // Can use stale for 1 day -while revalidating ); }), ``` +```dart +// Public cache with 1 hour expiration +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, // Cache for 1 hour + publicCache: true, // Shared cache allowed + mustRevalidate: true, // Must revalidate after expiry + staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating + ); +}), +``` -```dart // Secure defaults for sensitive data headers: Headers.build((h) { -h.cacheControl = CacheControlHeader( noStore: true, // Don't store anywhere -noCache: true, // Must revalidate privateCache: true, // Only private cache ); -}), ``` +```dart +// Secure defaults for sensitive data +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + noStore: true, // Don't store anywhere + noCache: true, // Must revalidate + privateCache: true, // Only private cache + ); +}), +``` Available directives: - `noCache`, `noStore` - Cache control flags @@ -904,32 +1183,65 @@ Available directives: Specify how content should be handled (inline display or download): -```dart // File download with proper filename headers: Headers.build((h) { -h.contentDisposition = ContentDispositionHeader( type: 'attachment', parameters: -[ ContentDispositionParameter(name: 'filename', value: 'report.pdf'), ], ); }), +```dart +// File download with proper filename +headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter(name: 'filename', value: 'report.pdf'), + ], + ); +}), ``` -```dart // With extended encoding (RFC 5987) for non-ASCII filenames -h.contentDisposition = ContentDispositionHeader( type: 'attachment', parameters: -[ ContentDispositionParameter( name: 'filename', value: 'rapport.pdf', -isExtended: true, encoding: 'UTF-8', ), ], ); ``` +```dart +// With extended encoding (RFC 5987) for non-ASCII filenames +h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: 'rapport.pdf', + isExtended: true, + encoding: 'UTF-8', + ), + ], +); +``` ### CookieHeader and SetCookieHeader - Cookies -**Reading cookies from requests:** ```dart final cookieHeader = -request.headers.cookie; +**Reading cookies from requests:** +```dart +final cookieHeader = request.headers.cookie; -if (cookieHeader != null) { // Find a specific cookie final sessionId = -cookieHeader.getCookie('session_id')?.value; +if (cookieHeader != null) { + // Find a specific cookie + final sessionId = cookieHeader.getCookie('session_id')?.value; - // Iterate all cookies for (final cookie in cookieHeader.cookies) { - print('${cookie.name}=${cookie.value}'); } } ``` + // Iterate all cookies + for (final cookie in cookieHeader.cookies) { + print('${cookie.name}=${cookie.value}'); + } +} +``` -**Setting cookies in responses:** ```dart headers: Headers.build((h) { -h.setCookie = SetCookieHeader( name: 'session_id', value: '12345abcde', maxAge: -3600, // 1 hour path: Uri.parse('/'), domain: Uri.parse('example.com'), secure: -true, // HTTPS only httpOnly: true, // No JavaScript access sameSite: -SameSite.strict, // CSRF protection ); }), ``` +**Setting cookies in responses:** +```dart +headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: '12345abcde', + maxAge: 3600, // 1 hour + path: Uri.parse('/'), + domain: Uri.parse('example.com'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.strict, // CSRF protection + ); +}), +``` SameSite values: - `SameSite.lax` - Default, not sent on cross-site requests (except navigation) @@ -938,171 +1250,287 @@ SameSite values: ### Complete examples -**Secure API with authentication and caching:** ```dart class SecureApiRoute -extends Route { @override Future handleCall(Session session, Request -request) async { // Check authorization final auth = -request.headers.authorization; if (auth is! BearerAuthorizationHeader) { return -Response.unauthorized(); } - - // Validate token if (!await validateToken(auth.token)) { return - Response.forbidden(); } - - // Return data with cache headers return Response.ok( headers: - Headers.build((h) { h.cacheControl = CacheControlHeader( maxAge: 300, // 5 - minutes publicCache: true, mustRevalidate: true, ); h.contentType = - ContentTypeHeader( mimeType: MimeType.json, charset: 'utf-8', ); }), body: - Body.fromString(jsonEncode(data)), ); } } ``` - -**File download with proper headers:** ```dart class DownloadRoute extends Route -{ @override Future handleCall(Session session, Request request) async { -final fileId = request.pathParameters[#fileId]; final file = await -getFile(session, fileId); - - return Response.ok( headers: Headers.build((h) { h.contentDisposition = - ContentDispositionHeader( type: 'attachment', parameters: [ - ContentDispositionParameter( name: 'filename', value: file.name, isExtended: - true, encoding: 'UTF-8', ), ], ); h.contentType = ContentTypeHeader( - mimeType: file.mimeType, ); h.cacheControl = CacheControlHeader( noCache: - true, mustRevalidate: true, ); }), body: Body.fromBytes(file.content), ); } - } ``` - -**Cookie-based sessions:** ```dart class LoginRoute extends Route { LoginRoute() -: super(methods: {Method.post}); - - @override Future handleCall(Session session, Request request) async { - // Authenticate user... final sessionToken = await - authenticateAndCreateSession(session, request); - - return Response.ok( headers: Headers.build((h) { h.setCookie = - SetCookieHeader( name: 'session_id', value: sessionToken, maxAge: 86400, // - 24 hours path: Uri.parse('/'), secure: true, // HTTPS only httpOnly: true, - // No JavaScript access sameSite: SameSite.lax, // CSRF protection ); }), - body: Body.fromString( jsonEncode({'status': 'logged_in'}), mimeType: - MimeType.json, ), ); } } ``` +**Secure API with authentication and caching:** +```dart +class SecureApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Check authorization + final auth = request.headers.authorization; + if (auth is! BearerAuthorizationHeader) { + return Response.unauthorized(); + } + + // Validate token + if (!await validateToken(auth.token)) { + return Response.forbidden(); + } + + // Return data with cache headers + return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 300, // 5 minutes + publicCache: true, + mustRevalidate: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + }), + body: Body.fromString(jsonEncode(data)), + ); + } +} +``` + +**File download with proper headers:** +```dart +class DownloadRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final fileId = request.pathParameters[#fileId]; + final file = await getFile(session, fileId); + + return Response.ok( + headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: file.name, + isExtended: true, + encoding: 'UTF-8', + ), + ], + ); + h.contentType = ContentTypeHeader( + mimeType: file.mimeType, + ); + h.cacheControl = CacheControlHeader( + noCache: true, + mustRevalidate: true, + ); + }), + body: Body.fromBytes(file.content), + ); + } +} +``` + +**Cookie-based sessions:** +```dart +class LoginRoute extends Route { + LoginRoute() : super(methods: {Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Authenticate user... + final sessionToken = await authenticateAndCreateSession(session, request); + + return Response.ok( + headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: sessionToken, + maxAge: 86400, // 24 hours + path: Uri.parse('/'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.lax, // CSRF protection + ); + }), + body: Body.fromString( + jsonEncode({'status': 'logged_in'}), + mimeType: MimeType.json, + ), + ); + } +} +``` :::tip Best Practices - Use typed headers for automatic parsing and validation - Set appropriate cache headers for better performance - Use `secure: true` and `httpOnly: true` for sensitive cookies - Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection ::: +- Use `SameSite` cookie attribute for CSRF protection +::: ### Creating custom typed headers -While Relic provides typed headers for all standard HTTP headers, your -application may use custom headers for API versioning, feature flags, or -application-specific metadata. Rather than falling back to string-based access -for these custom headers, you can create your own typed headers using the same -pattern Relic uses internally. +While Relic provides typed headers for all standard HTTP headers, your application may use custom headers for API versioning, feature flags, or application-specific metadata. Rather than falling back to string-based access for these custom headers, you can create your own typed headers using the same pattern Relic uses internally. -Creating a custom typed header involves three steps: defining the header class -with parsing logic, creating a codec for serialization, and setting up a -`HeaderAccessor` for type-safe access. Once configured, your custom headers work -just like the built-in ones, with automatic parsing, validation, and convenient -property-style access. +Creating a custom typed header involves three steps: defining the header class with parsing logic, creating a codec for serialization, and setting up a `HeaderAccessor` for type-safe access. Once configured, your custom headers work just like the built-in ones, with automatic parsing, validation, and convenient property-style access. Here's a complete example for a custom `X-API-Version` header: -```dart // Define your typed header class final class ApiVersionHeader { final -int major; final int minor; +```dart +// Define your typed header class +final class ApiVersionHeader { + final int major; + final int minor; ApiVersionHeader({required this.major, required this.minor}); - // Parse from string format "1.2" factory ApiVersionHeader.parse(String value) - { final parts = value.split('.'); if (parts.length != 2) { throw const - FormatException('Invalid API version format'); } + // Parse from string format "1.2" + factory ApiVersionHeader.parse(String value) { + final parts = value.split('.'); + if (parts.length != 2) { + throw const FormatException('Invalid API version format'); + } - final major = int.tryParse(parts[0]); final minor = int.tryParse(parts[1]); + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); - if (major == null || minor == null) { throw const FormatException('Invalid - API version numbers'); } + if (major == null || minor == null) { + throw const FormatException('Invalid API version numbers'); + } - return ApiVersionHeader(major: major, minor: minor); } + return ApiVersionHeader(major: major, minor: minor); + } - // Encode to string format String encode() => '$major.$minor'; + // Encode to string format + String encode() => '$major.$minor'; - @override bool operator ==(Object other) => identical(this, other) || other is - ApiVersionHeader && major == other.major && minor == other.minor; + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApiVersionHeader && + major == other.major && + minor == other.minor; - @override int get hashCode => Object.hash(major, minor); + @override + int get hashCode => Object.hash(major, minor); - @override String toString() => 'ApiVersionHeader($major.$minor)'; } + @override + String toString() => 'ApiVersionHeader($major.$minor)'; +} -// Create a HeaderCodec for encoding/decoding const _apiVersionCodec = -HeaderCodec.single( ApiVersionHeader.parse, (ApiVersionHeader value) => -[value.encode()], ); +// Create a HeaderCodec for encoding/decoding +const _apiVersionCodec = HeaderCodec.single( + ApiVersionHeader.parse, + (ApiVersionHeader value) => [value.encode()], +); -// Create a global HeaderAccessor const apiVersionHeader = HeaderAccessor( -'x-api-version', _apiVersionCodec, ); ``` +// Create a global HeaderAccessor +const apiVersionHeader = HeaderAccessor( + 'x-api-version', + _apiVersionCodec, +); +``` **Using your custom header:** -```dart // Reading the header class ApiRoute extends Route { @override -Future handleCall(Session session, Request request) async { final -version = apiVersionHeader[request.headers](); - - if (version != null && version.major < 2) { return Response.badRequest( - body: Body.fromString('API version 2.0 or higher required'), ); } +```dart +// Reading the header +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final version = apiVersionHeader[request.headers](); + + if (version != null && version.major < 2) { + return Response.badRequest( + body: Body.fromString('API version 2.0 or higher required'), + ); + } - return Response.ok(); } } + return Response.ok(); + } +} -// Setting the header return Response.ok( headers: Headers.build((h) { -apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); }), ); ``` +// Setting the header +return Response.ok( + headers: Headers.build((h) { + apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); + }), +); +``` **Multi-value header example:** For headers that can have multiple comma-separated values: -```dart final class CustomTagsHeader { final List tags; +```dart +final class CustomTagsHeader { + final List tags; - CustomTagsHeader({required List tags}) : tags = - List.unmodifiable(tags); + CustomTagsHeader({required List tags}) + : tags = List.unmodifiable(tags); - // Parse from multiple values or comma-separated factory - CustomTagsHeader.parse(Iterable values) { final allTags = values - .expand((v) => v.split(',')) .map((t) => t.trim()) .where((t) => t.isNotEmpty) - .toSet() .toList(); + // Parse from multiple values or comma-separated + factory CustomTagsHeader.parse(Iterable values) { + final allTags = values + .expand((v) => v.split(',')) + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toSet() + .toList(); - if (allTags.isEmpty) { throw const FormatException('Tags cannot be empty'); + if (allTags.isEmpty) { + throw const FormatException('Tags cannot be empty'); } - return CustomTagsHeader(tags: allTags); } + return CustomTagsHeader(tags: allTags); + } List encode() => [tags.join(', ')]; - @override bool operator ==(Object other) => identical(this, other) || other is - CustomTagsHeader && const ListEquality().equals(tags, other.tags); + @override + bool operator ==(Object other) => + identical(this, other) || + other is CustomTagsHeader && + const ListEquality().equals(tags, other.tags); - @override int get hashCode => const ListEquality().hash(tags); } + @override + int get hashCode => const ListEquality().hash(tags); +} -// Use HeaderCodec (not HeaderCodec.single) for multi-value const -_customTagsCodec = HeaderCodec( CustomTagsHeader.parse, (CustomTagsHeader value) -=> value.encode(), ); +// Use HeaderCodec (not HeaderCodec.single) for multi-value +const _customTagsCodec = HeaderCodec( + CustomTagsHeader.parse, + (CustomTagsHeader value) => value.encode(), +); -const customTagsHeader = HeaderAccessor( 'x-custom-tags', _customTagsCodec, ); +const customTagsHeader = HeaderAccessor( + 'x-custom-tags', + _customTagsCodec, +); ``` **Optional: Add extension methods for convenient access** -For better ergonomics, you can add extension methods to access your custom -headers with property syntax: +For better ergonomics, you can add extension methods to access your custom headers with property syntax: -```dart extension CustomHeadersEx on Headers { ApiVersionHeader? get apiVersion -=> apiVersionHeader[this](); CustomTagsHeader? get customTags => -customTagsHeader[this](); } +```dart +extension CustomHeadersEx on Headers { + ApiVersionHeader? get apiVersion => apiVersionHeader[this](); + CustomTagsHeader? get customTags => customTagsHeader[this](); +} -extension CustomMutableHeadersEx on MutableHeaders { set -apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); set -customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); } ``` +extension CustomMutableHeadersEx on MutableHeaders { + set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); + set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); +} +``` Now you can use property syntax instead of the bracket notation: -```dart // Reading with property syntax final version = -request.headers.apiVersion; final tags = request.headers.customTags; - -// Setting with property syntax return Response.ok( headers: Headers.build((h) { -h.apiVersion = ApiVersionHeader(major: 2, minor: 1); h.customTags = -CustomTagsHeader(tags: ['production', 'v2']); }), ); ``` +```dart +// Reading with property syntax +final version = request.headers.apiVersion; +final tags = request.headers.customTags; + +// Setting with property syntax +return Response.ok( + headers: Headers.build((h) { + h.apiVersion = ApiVersionHeader(major: 2, minor: 1); + h.customTags = CustomTagsHeader(tags: ['production', 'v2']); + }), +); +``` **Key points:** From 4106f7e34f1af9a218a40dbb185744706ca0c03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 12:43:44 +0100 Subject: [PATCH 21/39] docs: Rewrap with prettier (not zed) --- docs/06-concepts/18-webserver.md | 452 ++++++++++++++++++++----------- 1 file changed, 300 insertions(+), 152 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index e3d98521..31cd326e 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -1,22 +1,42 @@ # Web server -In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with third-party services. - -Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. +In addition to the application server, Serverpod comes with a built-in web +server. The web server allows you to access your database and business layer the +same way you would from a method call from an app. This makes it simple to share +data for applications that need both an app and traditional web pages. You can +also use the web server to create webhooks or generate custom REST APIs to +communicate with third-party services. + +Serverpod's web server is built on the +[Relic](https://github.com/serverpod/relic) framework, giving you access to its +routing engine, middleware system, and typed headers. This means you get the +benefits of Serverpod's database integration and business logic alongside +Relic's web server capabilities. :::caution -Serverpod's web server is still experimental, and the APIs may change in the future. This documentation should give you some hints on getting started, but we plan to add more extensive documentation as the web server matures. +Serverpod's web server is still experimental, and the APIs may change in the +future. This documentation should give you some hints on getting started, but we +plan to add more extensive documentation as the web server matures. ::: -When you create a new Serverpod project, it sets up a web server by default. When working with the web server, there are two main classes to understand; `WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders a web page using templates, while other `WebWidget` types can render JSON or other custom responses. +When you create a new Serverpod project, it sets up a web server by default. +When working with the web server, there are two main classes to understand; +`WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point +for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders +a web page using templates, while other `WebWidget` types can render JSON or +other custom responses. ## Creating new routes and widgets -To add new pages to your web server, you add new routes. Typically, you do this in your server.dart file before you start the Serverpod. By default, Serverpod comes with a `RootRoute` and a static directory. +To add new pages to your web server, you add new routes. Typically, you do this +in your server.dart file before you start the Serverpod. By default, Serverpod +comes with a `RootRoute` and a static directory. -When receiving a web request, Serverpod will search and match the routes in the order they were added. You can end a route's path with an asterisk (`*`) to match all paths with the same beginning. +When receiving a web request, Serverpod will search and match the routes in the +order they were added. You can end a route's path with an asterisk (`*`) to +match all paths with the same beginning. ```dart // Add a single page. @@ -26,7 +46,8 @@ pod.webServer.addRoute(MyRoute(), '/my/page/address'); pod.webServer.addRoute(AnotherRoute(), '/item/*'); ``` -Typically, you want to create custom routes for your pages. Do this by overriding the WidgetRoute class and implementing the build method. +Typically, you want to create custom routes for your pages. Do this by +overriding the WidgetRoute class and implementing the build method. ```dart class MyRoute extends WidgetRoute { @@ -37,7 +58,14 @@ class MyRoute extends WidgetRoute { } ``` -Your route's build method returns a `WebWidget`. The `TemplateWidget` consists of an HTML template file and a corresponding Dart class. Create a new custom widget by extending the `TemplateWidget` class. Then add a corresponding HTML template and place it in the `web/templates` directory. The HTML file uses the [Mustache](https://mustache.github.io/) template language. You set your template parameters by updating the `values` field of your widget class. The values are converted to `String` objects before being passed to the template. This makes it possible to nest widgets, similarly to how widgets work in Flutter. +Your route's build method returns a `WebWidget`. The `TemplateWidget` consists +of an HTML template file and a corresponding Dart class. Create a new custom +widget by extending the `TemplateWidget` class. Then add a corresponding HTML +template and place it in the `web/templates` directory. The HTML file uses the +[Mustache](https://mustache.github.io/) template language. You set your template +parameters by updating the `values` field of your widget class. The values are +converted to `String` objects before being passed to the template. This makes it +possible to nest widgets, similarly to how widgets work in Flutter. ```dart class MyPageWidget extends TemplateWidget { @@ -51,28 +79,40 @@ class MyPageWidget extends TemplateWidget { :::info -In the future, we plan to add a widget library to Serverpod with widgets corresponding to the standard widgets used by Flutter, such as Column, Row, Padding, Container, etc. This would make it possible to render server-side widgets with similar code used within Flutter. +In the future, we plan to add a widget library to Serverpod with widgets +corresponding to the standard widgets used by Flutter, such as Column, Row, +Padding, Container, etc. This would make it possible to render server-side +widgets with similar code used within Flutter. ::: ## Special widgets and routes -While `WidgetRoute` is great for custom HTML pages, Serverpod provides several built-in widgets and routes for common use cases. These special types automatically handle HTTP status codes and content types, so you don't need to configure them manually. +While `WidgetRoute` is great for custom HTML pages, Serverpod provides several +built-in widgets and routes for common use cases. These special types +automatically handle HTTP status codes and content types, so you don't need to +configure them manually. **Built-in widgets:** + - `ListWidget` - Concatenates multiple widgets into a single response - `JsonWidget` - Renders JSON documents from serializable data structures - `RedirectWidget` - Creates HTTP redirects to other URLs **Serving static files:** -Static assets like images, CSS, and JavaScript files are essential for web applications. The `StaticRoute.directory()` method makes it easy to serve entire directories with automatic content-type detection for common file formats. +Static assets like images, CSS, and JavaScript files are essential for web +applications. The `StaticRoute.directory()` method makes it easy to serve entire +directories with automatic content-type detection for common file formats. ### Static file cache-busting -When deploying static assets, browsers and CDNs (like CloudFront) cache files aggressively for performance. This means updated files may not be served to users unless you implement a cache-busting strategy. +When deploying static assets, browsers and CDNs (like CloudFront) cache files +aggressively for performance. This means updated files may not be served to +users unless you implement a cache-busting strategy. -Serverpod provides `CacheBustingConfig` to automatically version your static files: +Serverpod provides `CacheBustingConfig` to automatically version your static +files: ```dart final staticDir = Directory('web/static'); @@ -107,10 +147,12 @@ return MyPageWidget(logoUrl: imageUrl); ``` The cache-busting system: + - Automatically generates content-based hashes for asset versioning - Allows custom separators (default `@`, but you can use `___` or any other) - Preserves file extensions -- Works transparently - requesting `/static/logo@abc123.png` serves `/static/logo.png` +- Works transparently - requesting `/static/logo@abc123.png` serves + `/static/logo.png` **Combining with cache control:** @@ -128,19 +170,25 @@ pod.webServer.addRoute( ``` This approach ensures: + - Browsers cache files for a long time (better performance) - When files change, new hashes force cache invalidation - No manual version management needed ### Conditional requests (ETags and Last-Modified) -`StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file content when unchanged: +`StaticRoute` automatically supports HTTP conditional requests through Relic's +`StaticHandler`. This provides efficient caching without transferring file +content when unchanged: **Supported features:** + - **ETag headers** - Content-based fingerprinting for cache validation - **Last-Modified headers** - Timestamp-based cache validation -- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if unchanged -- **If-Modified-Since** - Client sends timestamp, server returns 304 if not modified +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if + unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not + modified These work automatically without configuration: @@ -161,21 +209,36 @@ These work automatically without configuration: // [no body - saves bandwidth] ``` -When combined with cache-busting, conditional requests provide a fallback validation mechanism even for cached assets, ensuring efficient delivery while maintaining correctness. +When combined with cache-busting, conditional requests provide a fallback +validation mechanism even for cached assets, ensuring efficient delivery while +maintaining correctness. ## Database access and logging -The web server passes a `Session` object to the `WidgetRoute` class' `build` method. This gives you access to all the features you typically get from a standard method call to an endpoint. Use the database, logging, or caching the same way you would in a method call. +The web server passes a `Session` object to the `WidgetRoute` class' `build` +method. This gives you access to all the features you typically get from a +standard method call to an endpoint. Use the database, logging, or caching the +same way you would in a method call. ## Advanced routing -The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern web applications often need more flexibility. Whether you're building REST APIs, handling file uploads, or creating webhooks for third-party integrations, Serverpod's routing system provides the tools you need. +The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern +web applications often need more flexibility. Whether you're building REST APIs, +handling file uploads, or creating webhooks for third-party integrations, +Serverpod's routing system provides the tools you need. -This section explores advanced routing patterns including custom route handlers, HTTP method handling, path parameters, wildcards, and modular route organization. These patterns give you fine-grained control over how your web server processes requests and generates responses. +This section explores advanced routing patterns including custom route handlers, +HTTP method handling, path parameters, wildcards, and modular route +organization. These patterns give you fine-grained control over how your web +server processes requests and generates responses. ### Custom Route classes -While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base class gives you complete control over request handling. By extending `Route` and implementing `handleCall()`, you can build REST APIs, serve files, or handle any custom HTTP interaction. This is ideal when you need to work directly with request bodies, headers, and response formats. +While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base +class gives you complete control over request handling. By extending `Route` and +implementing `handleCall()`, you can build REST APIs, serve files, or handle any +custom HTTP interaction. This is ideal when you need to work directly with +request bodies, headers, and response formats. ```dart class ApiRoute extends Route { @@ -188,7 +251,7 @@ class ApiRoute extends Route { // Read request body final body = await request.readAsString(); final data = jsonDecode(body); - + // Process and return JSON response return Response.ok( body: Body.fromString( @@ -197,7 +260,7 @@ class ApiRoute extends Route { ), ); } - + // Return data for GET requests return Response.ok( body: Body.fromString( @@ -212,13 +275,14 @@ class ApiRoute extends Route { pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` -:::info -The examples in this documentation omit error handling for brevity. See the Error Handling section in Middleware below for the recommended approach using global error handling middleware. -::: +:::info The examples in this documentation omit error handling for brevity. See +the Error Handling section in Middleware below for the recommended approach +using global error handling middleware. ::: ### HTTP methods -Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: +Routes can specify which HTTP methods they respond to using the `methods` +parameter. The available methods are: - `Method.get` - Retrieve data - `Method.post` - Create new resources @@ -252,7 +316,8 @@ class UserRoute extends Route { ### Path parameters -Routes support named path parameters using the `:paramName` syntax. These are automatically extracted and made available through the `Request` object: +Routes support named path parameters using the `:paramName` syntax. These are +automatically extracted and made available through the `Request` object: ```dart class UserRoute extends Route { @@ -268,26 +333,26 @@ class UserRoute extends Route { Future handleCall(Session session, Request request) async { // Extract path parameter using symbol final id = request.pathParameters[#id]; - + if (id == null) { return Response.badRequest( body: Body.fromString('Missing user ID'), ); } - + final userId = int.tryParse(id); if (userId == null) { return Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - + final user = await User.db.findById(session, userId); - + if (user == null) { return Response.notFound(); } - + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), @@ -322,9 +387,11 @@ pod.webServer.addRoute(ItemRoute(), '/item/*'); pod.webServer.addRoute(ItemRoute(), '/item/**'); ``` -:::info Performance Guarantee -The `/**` wildcard is a **tail-match** pattern and can only appear at the end of a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. This design ensures O(h) route lookup performance, where h is the path length, without backtracking. This keeps routing fast and predictable, even with many routes. -::: +:::info Performance Guarantee The `/**` wildcard is a **tail-match** pattern and +can only appear at the end of a route path (e.g., `/static/**`). Patterns like +`/a/**/b` are not supported. This design ensures O(h) route lookup performance, +where h is the path length, without backtracking. This keeps routing fast and +predictable, even with many routes. ::: Access the matched path information through the `Request` object: @@ -333,10 +400,10 @@ Access the matched path information through the `Request` object: Future handleCall(Session session, Request request) async { // Get the remaining path after the route prefix final remainingPath = request.url.path; - + // Access query parameters final query = request.url.queryParameters['query']; - + return Response.ok( body: Body.fromString('Path: $remainingPath, Query: $query'), ); @@ -363,12 +430,22 @@ pod.webServer.fallbackRoute = NotFoundRoute(); ### Modular routes with injectIn -As your web server grows, managing dozens of individual route registrations can become unwieldy. Modular routes solve this by letting you group related endpoints into reusable modules. For example, you might create a `UserCrudModule` that handles all user-related endpoints (`GET /users`, `POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. +As your web server grows, managing dozens of individual route registrations can +become unwieldy. Modular routes solve this by letting you group related +endpoints into reusable modules. For example, you might create a +`UserCrudModule` that handles all user-related endpoints (`GET /users`, +`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. -The key to modular routes is the `injectIn()` method. When you call `pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` on a router group for that path. By overriding `injectIn()`, you can register multiple handler functions instead of implementing a single `handleCall()` method. This pattern is perfect for REST resources, API modules, or any group of related endpoints. +The key to modular routes is the `injectIn()` method. When you call +`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` +on a router group for that path. By overriding `injectIn()`, you can register +multiple handler functions instead of implementing a single `handleCall()` +method. This pattern is perfect for REST resources, API modules, or any group of +related endpoints. -:::info Session Access in Modular Routes -When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use `request.session`: +:::info Session Access in Modular Routes When using `injectIn()` with handler +functions (`router.get('/', _handler)`), your handlers receive only a `Request` +parameter. To access the `Session`, use `request.session`: ```dart Future _handler(Request request) async { @@ -377,12 +454,14 @@ Future _handler(Request request) async { } ``` -This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. -::: +This differs from `Route.handleCall()` which receives both Session and Request +as explicit parameters. The modular route pattern uses Relic's router directly, +which only provides Request to handlers. ::: #### Creating a CRUD module -Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: +Here's an example of a modular CRUD route that registers multiple endpoints with +path parameters: ```dart class UserCrudModule extends Route { @@ -401,7 +480,7 @@ class UserCrudModule extends Route { Future _list(Request request) async { final session = request.session; final users = await User.db.find(session); - + return Response.ok( body: Body.fromString( jsonEncode(users.map((u) => u.toJson()).toList()), @@ -418,23 +497,23 @@ class UserCrudModule extends Route { body: Body.fromString('Missing user ID'), ); } - + final userId = int.tryParse(id); if (userId == null) { return Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - + final session = request.session; final user = await User.db.findById(session, userId); - + if (user == null) { return Response.notFound( body: Body.fromString('User not found'), ); } - + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), @@ -447,10 +526,10 @@ class UserCrudModule extends Route { final body = await request.readAsString(); final data = jsonDecode(body); final session = request.session; - + final user = User(name: data['name'], email: data['email']); await User.db.insertRow(session, user); - + return Response.created( body: Body.fromString( jsonEncode(user.toJson()), @@ -467,29 +546,29 @@ class UserCrudModule extends Route { body: Body.fromString('Missing user ID'), ); } - + final userId = int.tryParse(id); if (userId == null) { return Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - + final body = await request.readAsString(); final data = jsonDecode(body); final session = request.session; - + final user = await User.db.findById(session, userId); if (user == null) { return Response.notFound( body: Body.fromString('User not found'), ); } - + user.name = data['name'] ?? user.name; user.email = data['email'] ?? user.email; await User.db.updateRow(session, user); - + return Response.ok( body: Body.fromString( jsonEncode(user.toJson()), @@ -506,23 +585,23 @@ class UserCrudModule extends Route { body: Body.fromString('Missing user ID'), ); } - + final userId = int.tryParse(id); if (userId == null) { return Response.badRequest( body: Body.fromString('Invalid user ID'), ); } - + final session = request.session; final deleted = await User.db.deleteRow(session, userId); - + if (!deleted) { return Response.notFound( body: Body.fromString('User not found'), ); } - + return Response.noContent(); } @@ -538,15 +617,16 @@ pod.webServer.addRoute(UserCrudModule(), '/api/users'); ``` This creates the following RESTful endpoints: + - `GET /api/users` - List all users - `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) - `POST /api/users` - Create a new user - `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) - `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) -:::tip -Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. Always validate and parse these values since they come from user input as strings. -::: +:::tip Path parameters are accessed using symbols: +`request.pathParameters[#paramName]`. Always validate and parse these values +since they come from user input as strings. ::: #### Composing multiple modules @@ -560,7 +640,7 @@ class ApiModule extends Route { router.group('/users').inject(UserCrudModule()); router.group('/posts').inject(PostCrudModule()); router.group('/comments').inject(CommentCrudModule()); - + // Add module-level routes router.get('/health', _healthCheck); } @@ -585,20 +665,30 @@ pod.webServer.addRoute(ApiModule(), '/api'); ``` This pattern enables you to: + - **Organize routes hierarchically** - Group related functionality together - **Reuse route modules** - Use the same module in different applications - **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler implementation +- **Separate concerns** - Keep route registration logic separate from handler + implementation -:::tip -When overriding `injectIn`, you typically don't need to implement `handleCall` since you're registering handler functions directly with the router. You can throw `UnimplementedError` in `handleCall` to make it clear the method isn't used. -::: +:::tip When overriding `injectIn`, you typically don't need to implement +`handleCall` since you're registering handler functions directly with the +router. You can throw `UnimplementedError` in `handleCall` to make it clear the +method isn't used. ::: ## Middleware -Routes handle the core logic of your application, but many concerns cut across multiple routes: logging every request, validating API keys, handling CORS headers, or catching errors. Rather than duplicating this code in each route, middleware lets you apply it globally or to specific path prefixes. +Routes handle the core logic of your application, but many concerns cut across +multiple routes: logging every request, validating API keys, handling CORS +headers, or catching errors. Rather than duplicating this code in each route, +middleware lets you apply it globally or to specific path prefixes. -Middleware functions are wrappers that sit between the incoming request and your route handler. They can inspect or modify requests before they reach your routes, and transform responses before they're sent back to the client. This makes middleware perfect for authentication, logging, error handling, and any other cross-cutting concern in your web server. +Middleware functions are wrappers that sit between the incoming request and your +route handler. They can inspect or modify requests before they reach your +routes, and transform responses before they're sent back to the client. This +makes middleware perfect for authentication, logging, error handling, and any +other cross-cutting concern in your web server. ### Adding middleware @@ -614,20 +704,21 @@ pod.webServer.addMiddleware(authMiddleware, '/api'); ### Creating custom middleware -Middleware is a function that takes a `Handler` and returns a new `Handler`. Here's a simple logging middleware example: +Middleware is a function that takes a `Handler` and returns a new `Handler`. +Here's a simple logging middleware example: ```dart Handler loggingMiddleware(Handler innerHandler) { return (Request request) async { final start = DateTime.now(); print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); - + // Call the next handler in the chain final response = await innerHandler(request); - + final duration = DateTime.now().difference(start); print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); - + return response; }; } @@ -642,20 +733,20 @@ Handler apiKeyMiddleware(Handler innerHandler) { return (Request request) async { // Check for API key in header final apiKey = request.headers['X-API-Key']?.firstOrNull; - + if (apiKey == null) { return Response.unauthorized( body: Body.fromString('API key required'), ); } - + // Verify API key if (!await isValidApiKey(apiKey)) { return Response.forbidden( body: Body.fromString('Invalid API key'), ); } - + // Continue to the next handler return await innerHandler(request); }; @@ -665,9 +756,10 @@ Handler apiKeyMiddleware(Handler innerHandler) { pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); ``` -:::info -For user authentication, use Serverpod's built-in authentication system which integrates with the `Session` object. The middleware examples here are for additional web-specific validations like API keys, rate limiting, or request validation. -::: +:::info For user authentication, use Serverpod's built-in authentication system +which integrates with the `Session` object. The middleware examples here are for +additional web-specific validations like API keys, rate limiting, or request +validation. ::: ### CORS middleware @@ -686,10 +778,10 @@ Handler corsMiddleware(Handler innerHandler) { }), ); } - + // Process the request final response = await innerHandler(request); - + // Add CORS headers to response return response.change( headers: Headers.build((h) { @@ -704,11 +796,14 @@ pod.webServer.addMiddleware(corsMiddleware, '/api'); ### Error handling -Production applications need robust error handling. Rather than adding try-catch blocks to every route, use error handling middleware to catch exceptions globally and return consistent error responses. +Production applications need robust error handling. Rather than adding try-catch +blocks to every route, use error handling middleware to catch exceptions +globally and return consistent error responses. #### Error handling middleware -Error handling middleware wraps all your routes and catches any exceptions they throw: +Error handling middleware wraps all your routes and catches any exceptions they +throw: ```dart Handler errorHandlingMiddleware(Handler innerHandler) { @@ -727,7 +822,7 @@ Handler errorHandlingMiddleware(Handler innerHandler) { // Log the error print('Error handling ${request.method} ${request.url.path}: $e'); print(stackTrace); - + // Return a generic error response return Response.internalServerError( body: Body.fromString( @@ -743,17 +838,23 @@ Handler errorHandlingMiddleware(Handler innerHandler) { pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); ``` -With error handling middleware in place, your route handlers can focus on business logic without extensive try-catch blocks. The middleware catches common exceptions like: +With error handling middleware in place, your route handlers can focus on +business logic without extensive try-catch blocks. The middleware catches common +exceptions like: - `FormatException` from `jsonDecode()` - Returns 400 Bad Request - Database errors - Returns 500 Internal Server Error with logging - Any other uncaught exceptions - Returns 500 with error details logged -If an exception escapes all middleware, Serverpod will automatically return a 500 Internal Server Error response. However, using error handling middleware gives you control over error responses and logging. +If an exception escapes all middleware, Serverpod will automatically return a +500 Internal Server Error response. However, using error handling middleware +gives you control over error responses and logging. ### Middleware execution order -Middleware is applied based on path hierarchy, with more specific paths taking precedence. Within the same path, middleware executes in the order it was registered: +Middleware is applied based on path hierarchy, with more specific paths taking +precedence. Within the same path, middleware executes in the order it was +registered: ```dart pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) @@ -762,6 +863,7 @@ pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (inn ``` For a request to `/api/users`, the execution order is: + 1. `loggingMiddleware` (before) 2. `authMiddleware` (before) 3. `rateLimitMiddleware` (before) @@ -772,23 +874,38 @@ For a request to `/api/users`, the execution order is: ### Request-scoped data with ContextProperty -Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a subdomain, or a logging middleware might generate a request ID for tracing. Since `Request` objects are immutable, you can't just add properties to them. This is where `ContextProperty` comes in. +Middleware often needs to pass computed data to downstream handlers. For +example, a tenant identification middleware might extract the tenant ID from a +subdomain, or a logging middleware might generate a request ID for tracing. +Since `Request` objects are immutable, you can't just add properties to them. +This is where `ContextProperty` comes in. -`ContextProperty` provides a type-safe way to attach data to a `Request` object without modifying it. Think of it as a side channel for request-scoped data that middleware can write to and routes can read from. The data is automatically cleaned up when the request completes, making it perfect for per-request state. +`ContextProperty` provides a type-safe way to attach data to a `Request` +object without modifying it. Think of it as a side channel for request-scoped +data that middleware can write to and routes can read from. The data is +automatically cleaned up when the request completes, making it perfect for +per-request state. -:::info -Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter which includes authenticated user information if available. Use `ContextProperty` for web-specific request data that isn't part of the standard Session, such as request IDs, feature flags, or API version information extracted from headers. -::: +:::info Note that Serverpod's `Route.handleCall()` already receives a `Session` +parameter which includes authenticated user information if available. Use +`ContextProperty` for web-specific request data that isn't part of the standard +Session, such as request IDs, feature flags, or API version information +extracted from headers. ::: #### Why use ContextProperty? -Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that can be accessed by all downstream middleware and handlers. Common use cases include: +Since `Request` objects are immutable, you can't modify them directly. +`ContextProperty` allows you to associate additional data with a request that +can be accessed by all downstream middleware and handlers. Common use cases +include: -- **Request ID tracking** - Generated correlation IDs for distributed tracing (purely request-scoped, not session data) +- **Request ID tracking** - Generated correlation IDs for distributed tracing + (purely request-scoped, not session data) - **API versioning** - Extract and store API version from headers - **Feature flags** - Request-specific toggles based on headers or A/B testing - **Rate limiting** - Per-request rate limit state -- **Tenant identification** - Multi-tenant context from subdomains (when not part of user session) +- **Tenant identification** - Multi-tenant context from subdomains (when not + part of user session) #### Creating a ContextProperty @@ -809,7 +926,8 @@ final featureFlagsProperty = ContextProperty( #### Setting values in middleware -Middleware can set values on the context property, making them available to all downstream handlers: +Middleware can set values on the context property, making them available to all +downstream handlers: ```dart final requestIdProperty = ContextProperty(); @@ -818,19 +936,19 @@ Handler requestIdMiddleware(Handler innerHandler) { return (Request request) async { // Generate a unique request ID for tracing final requestId = Uuid().v4(); - + // Attach to request context requestIdProperty[request] = requestId; - + // Log the incoming request print('[$requestId] ${request.method} ${request.url.path}'); - + // Continue to next handler final response = await innerHandler(request); - + // Log the response print('[$requestId] Response: ${response.statusCode}'); - + // Optionally add request ID to response headers return response.change( headers: {'X-Request-ID': requestId}, @@ -849,13 +967,13 @@ class ApiRoute extends Route { Future handleCall(Session session, Request request) async { // Get the request ID from context final requestId = requestIdProperty[request]; - + // Use it for logging or tracing session.log('Processing request $requestId'); - + // Your route logic here final data = await processRequest(session); - + return Response.ok( body: Body.fromString( jsonEncode(data), @@ -876,7 +994,7 @@ class TenantRoute extends Route { Future handleCall(Session session, Request request) async { // Safely get tenant, returns null if not set final tenant = tenantProperty.getOrNull(request); - + if (tenant != null) { // Fetch tenant-specific data final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); @@ -904,13 +1022,13 @@ final tenantProperty = ContextProperty(); Handler tenantMiddleware(Handler innerHandler) { return (Request request) async { final host = request.headers.host; - + if (host == null) { return Response.badRequest( body: Body.fromString('Missing host header'), ); } - + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") final parts = host.host.split('.'); if (parts.length < 2) { @@ -918,22 +1036,22 @@ Handler tenantMiddleware(Handler innerHandler) { body: Body.fromString('Invalid hostname format'), ); } - + final tenant = parts.first; - + // Validate tenant exists (implement your own logic) final session = request.session; final tenantExists = await validateTenant(session, tenant); - + if (!tenantExists) { return Response.notFound( body: Body.fromString('Tenant not found'), ); } - + // Attach tenant to context tenantProperty[request] = tenant; - + return await innerHandler(request); }; } @@ -946,12 +1064,12 @@ class TenantDataRoute extends Route { @override Future handleCall(Session session, Request request) async { final tenant = tenantProperty[request]; - + // Fetch tenant-specific data final data = await session.db.find( where: (p) => p.tenantId.equals(tenant), ); - + return Response.ok( body: Body.fromString( jsonEncode(data.map((p) => p.toJson()).toList()), @@ -976,18 +1094,18 @@ Handler requestContextMiddleware(Handler innerHandler) { // Generate and attach request ID final requestId = Uuid().v4(); requestIdProperty[request] = requestId; - + // Extract tenant from subdomain or header final host = request.headers.host; if (host != null) { final tenant = host.host.split('.').first; tenantProperty[request] = tenant; } - + // Extract API version from header final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; apiVersionProperty[request] = apiVersion; - + return await innerHandler(request); }; } @@ -999,12 +1117,12 @@ class DataRoute extends Route { final requestId = requestIdProperty[request]; final tenant = tenantProperty[request]; final apiVersion = apiVersionProperty[request]; - + session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); - + // Fetch tenant-specific data final data = await fetchTenantData(session, tenant); - + return Response.ok( body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), ); @@ -1013,16 +1131,18 @@ class DataRoute extends Route { ``` :::tip Best Practices + - Define `ContextProperty` instances as top-level constants or static fields -- Use descriptive names for your properties (e.g., `requestIdProperty`, not just `requestId`) +- Use descriptive names for your properties (e.g., `requestIdProperty`, not just + `requestId`) - Use `getOrNull()` when the value might not be set - Set properties in middleware, not in routes -- Use specific types for better type safety -::: +- Use specific types for better type safety ::: ### Built-in logging middleware -Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient request logging: +Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient +request logging: ```dart import 'package:serverpod/serverpod.dart'; @@ -1034,9 +1154,16 @@ This logs all requests with method, path, status code, and response time. ## Typed headers -HTTP headers are traditionally accessed as strings, which means you need to manually parse values, handle edge cases, and validate formats. Serverpod's web server (via Relic) provides a better approach: typed headers that automatically parse header values into strongly-typed Dart objects. +HTTP headers are traditionally accessed as strings, which means you need to +manually parse values, handle edge cases, and validate formats. Serverpod's web +server (via Relic) provides a better approach: typed headers that automatically +parse header values into strongly-typed Dart objects. -For example, instead of parsing `Authorization: Bearer abc123` as a string and extracting the token yourself, you can access `request.headers.authorization` to get a `BearerAuthorizationHeader` object with the token already parsed and validated. This eliminates boilerplate code, catches errors early, and makes your code more maintainable. +For example, instead of parsing `Authorization: Bearer abc123` as a string and +extracting the token yourself, you can access `request.headers.authorization` to +get a `BearerAuthorizationHeader` object with the token already parsed and +validated. This eliminates boilerplate code, catches errors early, and makes +your code more maintainable. ### Reading typed headers @@ -1052,17 +1179,18 @@ class ApiRoute extends Route { final cookies = request.headers.cookie; // CookieHeader? final userAgent = request.headers.userAgent; // String? final host = request.headers.host; // HostHeader? - + // Raw string access is also available for any header final authRaw = request.headers['Authorization']; // Iterable? final custom = request.headers['X-Custom-Header']; // Iterable? - + return Response.ok(); } } ``` Common request headers include: + - `authorization` - AuthorizationHeader (Bearer/Basic/Digest) - `contentType` - ContentTypeHeader - `contentLength` - int @@ -1090,7 +1218,7 @@ return Response.ok( mimeType: MimeType.json, charset: 'utf-8', ); - + // Set custom headers (raw) h['X-API-Version'] = ['2.0']; }), @@ -1099,6 +1227,7 @@ return Response.ok( ``` Common response headers include: + - `cacheControl` - CacheControlHeader - `setCookie` - SetCookieHeader - `location` - Uri @@ -1111,12 +1240,13 @@ Common response headers include: The `AuthorizationHeader` supports three authentication schemes: **Bearer Token (JWT, OAuth):** + ```dart final auth = request.headers.authorization; if (auth is BearerAuthorizationHeader) { final token = auth.token; // The actual token string - + // Validate token if (!await validateToken(token)) { return Response.unauthorized(); @@ -1125,11 +1255,12 @@ if (auth is BearerAuthorizationHeader) { ``` **Basic Authentication:** + ```dart if (auth is BasicAuthorizationHeader) { final username = auth.username; final password = auth.password; - + // Validate credentials if (!await validateCredentials(username, password)) { return Response.unauthorized(); @@ -1138,6 +1269,7 @@ if (auth is BasicAuthorizationHeader) { ``` **Setting Bearer token:** + ```dart headers: Headers.build((h) { h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); @@ -1172,6 +1304,7 @@ headers: Headers.build((h) { ``` Available directives: + - `noCache`, `noStore` - Cache control flags - `maxAge`, `sMaxAge` - Seconds of freshness - `mustRevalidate`, `proxyRevalidate` - Revalidation requirements @@ -1213,13 +1346,14 @@ h.contentDisposition = ContentDispositionHeader( ### CookieHeader and SetCookieHeader - Cookies **Reading cookies from requests:** + ```dart final cookieHeader = request.headers.cookie; if (cookieHeader != null) { // Find a specific cookie final sessionId = cookieHeader.getCookie('session_id')?.value; - + // Iterate all cookies for (final cookie in cookieHeader.cookies) { print('${cookie.name}=${cookie.value}'); @@ -1228,6 +1362,7 @@ if (cookieHeader != null) { ``` **Setting cookies in responses:** + ```dart headers: Headers.build((h) { h.setCookie = SetCookieHeader( @@ -1244,6 +1379,7 @@ headers: Headers.build((h) { ``` SameSite values: + - `SameSite.lax` - Default, not sent on cross-site requests (except navigation) - `SameSite.strict` - Never sent on cross-site requests - `SameSite.none` - Sent on all requests (requires `secure: true`) @@ -1251,6 +1387,7 @@ SameSite values: ### Complete examples **Secure API with authentication and caching:** + ```dart class SecureApiRoute extends Route { @override @@ -1260,12 +1397,12 @@ class SecureApiRoute extends Route { if (auth is! BearerAuthorizationHeader) { return Response.unauthorized(); } - + // Validate token if (!await validateToken(auth.token)) { return Response.forbidden(); } - + // Return data with cache headers return Response.ok( headers: Headers.build((h) { @@ -1286,13 +1423,14 @@ class SecureApiRoute extends Route { ``` **File download with proper headers:** + ```dart class DownloadRoute extends Route { @override Future handleCall(Session session, Request request) async { final fileId = request.pathParameters[#fileId]; final file = await getFile(session, fileId); - + return Response.ok( headers: Headers.build((h) { h.contentDisposition = ContentDispositionHeader( @@ -1321,6 +1459,7 @@ class DownloadRoute extends Route { ``` **Cookie-based sessions:** + ```dart class LoginRoute extends Route { LoginRoute() : super(methods: {Method.post}); @@ -1329,7 +1468,7 @@ class LoginRoute extends Route { Future handleCall(Session session, Request request) async { // Authenticate user... final sessionToken = await authenticateAndCreateSession(session, request); - + return Response.ok( headers: Headers.build((h) { h.setCookie = SetCookieHeader( @@ -1352,18 +1491,26 @@ class LoginRoute extends Route { ``` :::tip Best Practices + - Use typed headers for automatic parsing and validation - Set appropriate cache headers for better performance - Use `secure: true` and `httpOnly: true` for sensitive cookies - Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection -::: +- Use `SameSite` cookie attribute for CSRF protection ::: ### Creating custom typed headers -While Relic provides typed headers for all standard HTTP headers, your application may use custom headers for API versioning, feature flags, or application-specific metadata. Rather than falling back to string-based access for these custom headers, you can create your own typed headers using the same pattern Relic uses internally. +While Relic provides typed headers for all standard HTTP headers, your +application may use custom headers for API versioning, feature flags, or +application-specific metadata. Rather than falling back to string-based access +for these custom headers, you can create your own typed headers using the same +pattern Relic uses internally. -Creating a custom typed header involves three steps: defining the header class with parsing logic, creating a codec for serialization, and setting up a `HeaderAccessor` for type-safe access. Once configured, your custom headers work just like the built-in ones, with automatic parsing, validation, and convenient property-style access. +Creating a custom typed header involves three steps: defining the header class +with parsing logic, creating a codec for serialization, and setting up a +`HeaderAccessor` for type-safe access. Once configured, your custom headers work +just like the built-in ones, with automatic parsing, validation, and convenient +property-style access. Here's a complete example for a custom `X-API-Version` header: @@ -1381,14 +1528,14 @@ final class ApiVersionHeader { if (parts.length != 2) { throw const FormatException('Invalid API version format'); } - + final major = int.tryParse(parts[0]); final minor = int.tryParse(parts[1]); - + if (major == null || minor == null) { throw const FormatException('Invalid API version numbers'); } - + return ApiVersionHeader(major: major, minor: minor); } @@ -1430,13 +1577,13 @@ class ApiRoute extends Route { @override Future handleCall(Session session, Request request) async { final version = apiVersionHeader[request.headers](); - + if (version != null && version.major < 2) { return Response.badRequest( body: Body.fromString('API version 2.0 or higher required'), ); } - + return Response.ok(); } } @@ -1468,11 +1615,11 @@ final class CustomTagsHeader { .where((t) => t.isNotEmpty) .toSet() .toList(); - + if (allTags.isEmpty) { throw const FormatException('Tags cannot be empty'); } - + return CustomTagsHeader(tags: allTags); } @@ -1502,7 +1649,8 @@ const customTagsHeader = HeaderAccessor( **Optional: Add extension methods for convenient access** -For better ergonomics, you can add extension methods to access your custom headers with property syntax: +For better ergonomics, you can add extension methods to access your custom +headers with property syntax: ```dart extension CustomHeadersEx on Headers { From 5097b1186d8233ea93a4389fb1f4334357c7bfcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 13:02:54 +0100 Subject: [PATCH 22/39] docs: Fix hyphenation nit --- docs/06-concepts/18-webserver.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 31cd326e..218bed32 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -277,7 +277,7 @@ pod.webServer.addRoute(ApiRoute(), '/api/data'); :::info The examples in this documentation omit error handling for brevity. See the Error Handling section in Middleware below for the recommended approach -using global error handling middleware. ::: +using global error-handling middleware. ::: ### HTTP methods @@ -797,12 +797,12 @@ pod.webServer.addMiddleware(corsMiddleware, '/api'); ### Error handling Production applications need robust error handling. Rather than adding try-catch -blocks to every route, use error handling middleware to catch exceptions +blocks to every route, use error-handling middleware to catch exceptions globally and return consistent error responses. -#### Error handling middleware +#### Error-handling middleware -Error handling middleware wraps all your routes and catches any exceptions they +Error-handling middleware wraps all your routes and catches any exceptions they throw: ```dart @@ -838,7 +838,7 @@ Handler errorHandlingMiddleware(Handler innerHandler) { pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); ``` -With error handling middleware in place, your route handlers can focus on +With error-handling middleware in place, your route handlers can focus on business logic without extensive try-catch blocks. The middleware catches common exceptions like: @@ -847,7 +847,7 @@ exceptions like: - Any other uncaught exceptions - Returns 500 with error details logged If an exception escapes all middleware, Serverpod will automatically return a -500 Internal Server Error response. However, using error handling middleware +500 Internal Server Error response. However, using error-handling middleware gives you control over error responses and logging. ### Middleware execution order From 8060dad270ee3e446aa17be544855e390e9aed6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Thu, 20 Nov 2025 13:05:33 +0100 Subject: [PATCH 23/39] docs: Use proper HTTP code blocks for conditional request examples Replace Dart comment pseudo-code with actual HTTP request/response format using http language tag. Separates initial and subsequent requests for clarity. --- docs/06-concepts/18-webserver.md | 38 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md index 218bed32..42c52f24 100644 --- a/docs/06-concepts/18-webserver.md +++ b/docs/06-concepts/18-webserver.md @@ -192,21 +192,29 @@ content when unchanged: These work automatically without configuration: -```dart -// Client first request: -// GET /static/logo.png -// Response: 200 OK -// ETag: "abc123" -// Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT -// Content-Length: 12345 -// [file content] - -// Client subsequent request: -// GET /static/logo.png -// If-None-Match: "abc123" -// Response: 304 Not Modified -// ETag: "abc123" -// [no body - saves bandwidth] +**Initial request:** +```http +GET /static/logo.png HTTP/1.1 +Host: example.com + +HTTP/1.1 200 OK +ETag: "abc123" +Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT +Content-Length: 12345 + +[file content] +``` + +**Subsequent request with ETag:** +```http +GET /static/logo.png HTTP/1.1 +Host: example.com +If-None-Match: "abc123" + +HTTP/1.1 304 Not Modified +ETag: "abc123" + +[no body - saves bandwidth] ``` When combined with cache-busting, conditional requests provide a fallback From 36606c475eeeeeed66f16ea98d6e7bb1fe62211f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 21 Nov 2025 11:56:23 +0100 Subject: [PATCH 24/39] docs: Split WebServer documentation into multiple focused pages - Created docs/06-concepts/18-webserver/ directory with 7 focused pages - Added docs/01-get-started/05-web-server.md quick start guide - Organized content by feature: routing, modular routes, middleware, static files, typed headers, server-side HTML - De-emphasized WidgetRoute/TemplateWidget with Jaspr recommendation - Added _category_.json for Docusaurus sidebar navigation - Improved discoverability with navigation between related topics - Remove experimental warnings from web server documentation --- docs/01-get-started/04-web-server.md | 213 +++ .../{04-deployment.md => 05-deployment.md} | 2 +- docs/06-concepts/18-webserver.md | 1699 ----------------- docs/06-concepts/18-webserver/01-overview.md | 154 ++ docs/06-concepts/18-webserver/02-routing.md | 217 +++ .../18-webserver/03-modular-routes.md | 265 +++ .../06-concepts/18-webserver/04-middleware.md | 472 +++++ .../18-webserver/05-static-files.md | 187 ++ .../18-webserver/06-typed-headers.md | 543 ++++++ .../18-webserver/07-server-side-html.md | 128 ++ docs/06-concepts/18-webserver/_category_.json | 4 + 11 files changed, 2184 insertions(+), 1700 deletions(-) create mode 100644 docs/01-get-started/04-web-server.md rename docs/01-get-started/{04-deployment.md => 05-deployment.md} (98%) delete mode 100644 docs/06-concepts/18-webserver.md create mode 100644 docs/06-concepts/18-webserver/01-overview.md create mode 100644 docs/06-concepts/18-webserver/02-routing.md create mode 100644 docs/06-concepts/18-webserver/03-modular-routes.md create mode 100644 docs/06-concepts/18-webserver/04-middleware.md create mode 100644 docs/06-concepts/18-webserver/05-static-files.md create mode 100644 docs/06-concepts/18-webserver/06-typed-headers.md create mode 100644 docs/06-concepts/18-webserver/07-server-side-html.md create mode 100644 docs/06-concepts/18-webserver/_category_.json diff --git a/docs/01-get-started/04-web-server.md b/docs/01-get-started/04-web-server.md new file mode 100644 index 00000000..efd07a6f --- /dev/null +++ b/docs/01-get-started/04-web-server.md @@ -0,0 +1,213 @@ +--- +sidebar_label: 4. Working with the web server +--- + +# Working with the web server + +In addition to endpoints for your Flutter app, Serverpod includes a built-in web server for REST APIs, static files, and webhooks. This is useful when you need to integrate with third-party services, serve web pages, or provide public APIs. The web server gives you full access to your database and business logic through the `Session` object, just like regular endpoints. + +:::info +The web server is built on the [Relic](https://github.com/serverpod/relic) framework, which provides routing, middleware, typed headers, and more. You get the benefits of Serverpod's database integration combined with Relic's web server capabilities. +::: + +## Building a REST API + +Let's create a complete REST API for managing recipes with support for listing, creating, retrieving, and deleting recipes. Create a new file `magic_recipe_server/lib/src/routes/recipe_route.dart`: + +```dart +import 'dart:convert'; +import 'package:serverpod/serverpod.dart'; + +class RecipeRoute extends Route { + // Specify which HTTP methods this route accepts + RecipeRoute() : super(methods: {Method.get, Method.post, Method.delete}); + + // Override injectIn to register multiple handler functions for different paths + // This is called "modular routing" and lets you organize related endpoints together + @override + void injectIn(RelicRouter router) { + router + ..get('/', _list) // GET /api/recipes + ..get('/:id', _get) // GET /api/recipes/123 + ..post('/', _create) // POST /api/recipes + ..delete('/:id', _delete); // DELETE /api/recipes/123 + } + + Future _list(Request request) async { + // Access the Session through request.session (modular routes only get Request) + final recipes = await Recipe.db.find(request.session, limit: 10); + + return Response.ok( + body: Body.fromString( + jsonEncode({'recipes': recipes.map((r) => r.toJson()).toList()}), + mimeType: MimeType.json, // Set proper content type + ), + ); + } + + Future _get(Request request) async { + // Path parameters are accessed using symbols: pathParameters[#id] + final id = int.tryParse(request.pathParameters[#id] ?? ''); + if (id == null) return Response.badRequest(); + + final recipe = await Recipe.db.findById(request.session, id); + + // Return different status codes based on the result + if (recipe == null) return Response.notFound(); + + return Response.ok( + body: Body.fromString(jsonEncode(recipe.toJson()), mimeType: MimeType.json), + ); + } + + Future _create(Request request) async { + // Read and parse the request body + final data = jsonDecode(await request.readAsString()); + final recipe = Recipe( + title: data['title'], + ingredients: data['ingredients'], + ); + await Recipe.db.insertRow(request.session, recipe); + + // Return 201 Created with the new resource + return Response.created( + body: Body.fromString(jsonEncode(recipe.toJson()), mimeType: MimeType.json), + ); + } + + Future _delete(Request request) async { + final id = int.tryParse(request.pathParameters[#id] ?? ''); + if (id == null) return Response.badRequest(); + + await Recipe.db.deleteRow(request.session, id); + + // 204 No Content is appropriate for successful DELETE + return Response.noContent(); + } + + // When using injectIn, handleCall is not used + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('Uses injectIn'); + } +} +``` + +Register the route in your `server.dart` file before calling `pod.start()`: + +```dart +// Add your web routes here +pod.webServer.addRoute(RecipeRoute(), '/api/recipes'); + +await pod.start(); +``` + +This creates a complete CRUD API: +- `GET /api/recipes` - List all recipes +- `GET /api/recipes/123` - Get a specific recipe by ID +- `POST /api/recipes` - Create a new recipe +- `DELETE /api/recipes/123` - Delete a recipe + +You can test it with curl: + +```bash +# List recipes +curl http://localhost:8080/api/recipes + +# Get a specific recipe +curl http://localhost:8080/api/recipes/1 + +# Create a new recipe +curl -X POST http://localhost:8080/api/recipes \ + -H "Content-Type: application/json" \ + -d '{"title":"Pasta","ingredients":"Tomatoes, pasta, basil"}' + +# Delete a recipe +curl -X DELETE http://localhost:8080/api/recipes/1 +``` + +## Middleware for cross-cutting concerns + +Middleware lets you add functionality that applies to multiple routes, like logging, authentication, or error handling. Middleware functions wrap your route handlers and can inspect or modify requests and responses. + +```dart +// Add this before your route registrations +Handler loggingMiddleware(Handler next) { + return (Request request) async { + final start = DateTime.now(); + print('→ ${request.method.name} ${request.url.path}'); + + // Call the next handler in the chain + final response = await next(request); + + final duration = DateTime.now().difference(start); + print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); + + return response; + }; +} + +// Apply to all routes +pod.webServer.addMiddleware(loggingMiddleware, '/'); +``` + +You can add multiple middleware functions and scope them to specific paths: + +```dart +// Only apply to API routes +pod.webServer.addMiddleware(authenticationMiddleware, '/api'); +``` + +## Serving static files + +For serving CSS, JavaScript, images, and other static assets, use `StaticRoute`: + +```dart +import 'dart:io'; + +// Serve files from the web/static directory +pod.webServer.addRoute( + StaticRoute.directory( + Directory('web/static'), + // Optional: set cache control for better performance + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), + ), + '/static/**', // The /** wildcard matches all paths under /static/ +); +``` + +Now files in `web/static/` are accessible at `/static/`: +- `web/static/logo.png` → `http://localhost:8080/static/logo.png` +- `web/static/css/style.css` → `http://localhost:8080/static/css/style.css` + +## Advanced features + +The web server includes many more features for production-ready APIs: + +**Typed headers** - Access headers in a type-safe way instead of raw strings: + +```dart +// Instead of: request.headers['Authorization'] +final auth = request.headers.authorization; // Returns AuthorizationHeader? +if (auth is BearerAuthorizationHeader) { + final token = auth.token; // Automatically parsed +} +``` + +**ContextProperty** - Attach request-scoped data that middleware can set and routes can read: + +```dart +final requestIdProperty = ContextProperty(); + +// In middleware: attach a request ID +requestIdProperty[request] = Uuid().v4(); + +// In route: access the request ID +final requestId = requestIdProperty[request]; +``` + +**Webhooks** - Handle incoming webhooks from third-party services by validating signatures and processing events. + +**Cache-busting** - Automatically version static assets with content hashes for optimal caching. + +See the full [Web Server documentation](../concepts/webserver/overview) for details on [routing](../concepts/webserver/routing), [middleware](../concepts/webserver/middleware), [typed headers](../concepts/webserver/typed-headers), [static files](../concepts/webserver/static-files), and more. diff --git a/docs/01-get-started/04-deployment.md b/docs/01-get-started/05-deployment.md similarity index 98% rename from docs/01-get-started/04-deployment.md rename to docs/01-get-started/05-deployment.md index 4c39970b..dd2482c5 100644 --- a/docs/01-get-started/04-deployment.md +++ b/docs/01-get-started/05-deployment.md @@ -1,5 +1,5 @@ --- -sidebar_label: 4. Deploying Serverpod +sidebar_label: 5. Deploying Serverpod --- # Deploying Serverpod diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md deleted file mode 100644 index 42c52f24..00000000 --- a/docs/06-concepts/18-webserver.md +++ /dev/null @@ -1,1699 +0,0 @@ -# Web server - -In addition to the application server, Serverpod comes with a built-in web -server. The web server allows you to access your database and business layer the -same way you would from a method call from an app. This makes it simple to share -data for applications that need both an app and traditional web pages. You can -also use the web server to create webhooks or generate custom REST APIs to -communicate with third-party services. - -Serverpod's web server is built on the -[Relic](https://github.com/serverpod/relic) framework, giving you access to its -routing engine, middleware system, and typed headers. This means you get the -benefits of Serverpod's database integration and business logic alongside -Relic's web server capabilities. - -:::caution - -Serverpod's web server is still experimental, and the APIs may change in the -future. This documentation should give you some hints on getting started, but we -plan to add more extensive documentation as the web server matures. - -::: - -When you create a new Serverpod project, it sets up a web server by default. -When working with the web server, there are two main classes to understand; -`WidgetRoute` and `TemplateWidget`. The `WidgetRoute` provides an entry point -for a call to the server and returns a `WebWidget`. The `TemplateWidget` renders -a web page using templates, while other `WebWidget` types can render JSON or -other custom responses. - -## Creating new routes and widgets - -To add new pages to your web server, you add new routes. Typically, you do this -in your server.dart file before you start the Serverpod. By default, Serverpod -comes with a `RootRoute` and a static directory. - -When receiving a web request, Serverpod will search and match the routes in the -order they were added. You can end a route's path with an asterisk (`*`) to -match all paths with the same beginning. - -```dart -// Add a single page. -pod.webServer.addRoute(MyRoute(), '/my/page/address'); - -// Match all paths that start with /item/ -pod.webServer.addRoute(AnotherRoute(), '/item/*'); -``` - -Typically, you want to create custom routes for your pages. Do this by -overriding the WidgetRoute class and implementing the build method. - -```dart -class MyRoute extends WidgetRoute { - @override - Future build(Session session, Request request) async { - return MyPageWidget(title: 'Home page'); - } -} -``` - -Your route's build method returns a `WebWidget`. The `TemplateWidget` consists -of an HTML template file and a corresponding Dart class. Create a new custom -widget by extending the `TemplateWidget` class. Then add a corresponding HTML -template and place it in the `web/templates` directory. The HTML file uses the -[Mustache](https://mustache.github.io/) template language. You set your template -parameters by updating the `values` field of your widget class. The values are -converted to `String` objects before being passed to the template. This makes it -possible to nest widgets, similarly to how widgets work in Flutter. - -```dart -class MyPageWidget extends TemplateWidget { - MyPageWidget({required String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` - -:::info - -In the future, we plan to add a widget library to Serverpod with widgets -corresponding to the standard widgets used by Flutter, such as Column, Row, -Padding, Container, etc. This would make it possible to render server-side -widgets with similar code used within Flutter. - -::: - -## Special widgets and routes - -While `WidgetRoute` is great for custom HTML pages, Serverpod provides several -built-in widgets and routes for common use cases. These special types -automatically handle HTTP status codes and content types, so you don't need to -configure them manually. - -**Built-in widgets:** - -- `ListWidget` - Concatenates multiple widgets into a single response -- `JsonWidget` - Renders JSON documents from serializable data structures -- `RedirectWidget` - Creates HTTP redirects to other URLs - -**Serving static files:** - -Static assets like images, CSS, and JavaScript files are essential for web -applications. The `StaticRoute.directory()` method makes it easy to serve entire -directories with automatic content-type detection for common file formats. - -### Static file cache-busting - -When deploying static assets, browsers and CDNs (like CloudFront) cache files -aggressively for performance. This means updated files may not be served to -users unless you implement a cache-busting strategy. - -Serverpod provides `CacheBustingConfig` to automatically version your static -files: - -```dart -final staticDir = Directory('web/static'); - -final cacheBustingConfig = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticDir, - separator: '@', // or use custom separator like '___' -); - -pod.webServer.addRoute( - StaticRoute.directory( - staticDir, - cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), - ), - '/static/**', -); -``` - -**Generating versioned URLs:** - -Use the `assetPath()` method to generate cache-busted URLs for your assets: - -```dart -// In your route handler -final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); -// Returns: /static/logo@.png - -// Pass to your template -return MyPageWidget(logoUrl: imageUrl); -``` - -The cache-busting system: - -- Automatically generates content-based hashes for asset versioning -- Allows custom separators (default `@`, but you can use `___` or any other) -- Preserves file extensions -- Works transparently - requesting `/static/logo@abc123.png` serves - `/static/logo.png` - -**Combining with cache control:** - -For optimal performance, combine cache-busting with aggressive caching: - -```dart -pod.webServer.addRoute( - StaticRoute.directory( - staticDir, - cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year - ), - '/static/**', -); -``` - -This approach ensures: - -- Browsers cache files for a long time (better performance) -- When files change, new hashes force cache invalidation -- No manual version management needed - -### Conditional requests (ETags and Last-Modified) - -`StaticRoute` automatically supports HTTP conditional requests through Relic's -`StaticHandler`. This provides efficient caching without transferring file -content when unchanged: - -**Supported features:** - -- **ETag headers** - Content-based fingerprinting for cache validation -- **Last-Modified headers** - Timestamp-based cache validation -- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if - unchanged -- **If-Modified-Since** - Client sends timestamp, server returns 304 if not - modified - -These work automatically without configuration: - -**Initial request:** -```http -GET /static/logo.png HTTP/1.1 -Host: example.com - -HTTP/1.1 200 OK -ETag: "abc123" -Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT -Content-Length: 12345 - -[file content] -``` - -**Subsequent request with ETag:** -```http -GET /static/logo.png HTTP/1.1 -Host: example.com -If-None-Match: "abc123" - -HTTP/1.1 304 Not Modified -ETag: "abc123" - -[no body - saves bandwidth] -``` - -When combined with cache-busting, conditional requests provide a fallback -validation mechanism even for cached assets, ensuring efficient delivery while -maintaining correctness. - -## Database access and logging - -The web server passes a `Session` object to the `WidgetRoute` class' `build` -method. This gives you access to all the features you typically get from a -standard method call to an endpoint. Use the database, logging, or caching the -same way you would in a method call. - -## Advanced routing - -The basic `WidgetRoute` is perfect for server-rendered HTML pages, but modern -web applications often need more flexibility. Whether you're building REST APIs, -handling file uploads, or creating webhooks for third-party integrations, -Serverpod's routing system provides the tools you need. - -This section explores advanced routing patterns including custom route handlers, -HTTP method handling, path parameters, wildcards, and modular route -organization. These patterns give you fine-grained control over how your web -server processes requests and generates responses. - -### Custom Route classes - -While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base -class gives you complete control over request handling. By extending `Route` and -implementing `handleCall()`, you can build REST APIs, serve files, or handle any -custom HTTP interaction. This is ideal when you need to work directly with -request bodies, headers, and response formats. - -```dart -class ApiRoute extends Route { - ApiRoute() : super(methods: {Method.get, Method.post}); - - @override - Future handleCall(Session session, Request request) async { - // Access request method - if (request.method == Method.post) { - // Read request body - final body = await request.readAsString(); - final data = jsonDecode(body); - - // Process and return JSON response - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'success', 'data': data}), - mimeType: MimeType.json, - ), - ); - } - - // Return data for GET requests - return Response.ok( - body: Body.fromString( - jsonEncode({'message': 'Hello from API'}), - mimeType: MimeType.json, - ), - ); - } -} - -// Register the route -pod.webServer.addRoute(ApiRoute(), '/api/data'); -``` - -:::info The examples in this documentation omit error handling for brevity. See -the Error Handling section in Middleware below for the recommended approach -using global error-handling middleware. ::: - -### HTTP methods - -Routes can specify which HTTP methods they respond to using the `methods` -parameter. The available methods are: - -- `Method.get` - Retrieve data -- `Method.post` - Create new resources -- `Method.put` - Update resources (full replacement) -- `Method.patch` - Update resources (partial) -- `Method.delete` - Delete resources -- `Method.head` - Same as GET but without response body -- `Method.options` - Query supported methods (used for CORS) - -```dart -class UserRoute extends Route { - UserRoute() : super( - methods: {Method.get, Method.post, Method.delete}, - ); - - @override - Future handleCall(Session session, Request request) async { - switch (request.method) { - case Method.get: - return await _getUser(request); - case Method.post: - return await _createUser(request); - case Method.delete: - return await _deleteUser(request); - default: - return Response.methodNotAllowed(); - } - } -} -``` - -### Path parameters - -Routes support named path parameters using the `:paramName` syntax. These are -automatically extracted and made available through the `Request` object: - -```dart -class UserRoute extends Route { - UserRoute() : super(methods: {Method.get}); - - @override - void injectIn(RelicRouter router) { - // Define route with path parameter - router.get('/:id', asHandler); - } - - @override - Future handleCall(Session session, Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final user = await User.db.findById(session, userId); - - if (user == null) { - return Response.notFound(); - } - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } -} - -// Register at /api/users - will match /api/users/123 -pod.webServer.addRoute(UserRoute(), '/api/users'); -``` - -You can use multiple path parameters in a single route: - -```dart -router.get('/:userId/posts/:postId', handler); -// Matches: /123/posts/456 -// request.pathParameters[#userId] => '123' -// request.pathParameters[#postId] => '456' -``` - -### Wildcards - -Routes also support wildcard matching for catching all paths: - -```dart -// Single-level wildcard - matches /item/foo but not /item/foo/bar -pod.webServer.addRoute(ItemRoute(), '/item/*'); - -// Tail-match wildcard - matches /item/foo and /item/foo/bar/baz -pod.webServer.addRoute(ItemRoute(), '/item/**'); -``` - -:::info Performance Guarantee The `/**` wildcard is a **tail-match** pattern and -can only appear at the end of a route path (e.g., `/static/**`). Patterns like -`/a/**/b` are not supported. This design ensures O(h) route lookup performance, -where h is the path length, without backtracking. This keeps routing fast and -predictable, even with many routes. ::: - -Access the matched path information through the `Request` object: - -```dart -@override -Future handleCall(Session session, Request request) async { - // Get the remaining path after the route prefix - final remainingPath = request.url.path; - - // Access query parameters - final query = request.url.queryParameters['query']; - - return Response.ok( - body: Body.fromString('Path: $remainingPath, Query: $query'), - ); -} -``` - -### Fallback routes - -You can set a fallback route that handles requests when no other route matches: - -```dart -class NotFoundRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - return Response.notFound( - body: Body.fromString('Page not found: ${request.url.path}'), - ); - } -} - -// Set as fallback -pod.webServer.fallbackRoute = NotFoundRoute(); -``` - -### Modular routes with injectIn - -As your web server grows, managing dozens of individual route registrations can -become unwieldy. Modular routes solve this by letting you group related -endpoints into reusable modules. For example, you might create a -`UserCrudModule` that handles all user-related endpoints (`GET /users`, -`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. - -The key to modular routes is the `injectIn()` method. When you call -`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` -on a router group for that path. By overriding `injectIn()`, you can register -multiple handler functions instead of implementing a single `handleCall()` -method. This pattern is perfect for REST resources, API modules, or any group of -related endpoints. - -:::info Session Access in Modular Routes When using `injectIn()` with handler -functions (`router.get('/', _handler)`), your handlers receive only a `Request` -parameter. To access the `Session`, use `request.session`: - -```dart -Future _handler(Request request) async { - final session = request.session; // Extract Session from Request - // ... use session -} -``` - -This differs from `Route.handleCall()` which receives both Session and Request -as explicit parameters. The modular route pattern uses Relic's router directly, -which only provides Request to handlers. ::: - -#### Creating a CRUD module - -Here's an example of a modular CRUD route that registers multiple endpoints with -path parameters: - -```dart -class UserCrudModule extends Route { - @override - void injectIn(RelicRouter router) { - // Register multiple routes with path parameters - router - ..get('/', _list) - ..get('/:id', _get) - ..post('/', _create) - ..put('/:id', _update) - ..delete('/:id', _delete); - } - - // Handler methods - Future _list(Request request) async { - final session = request.session; - final users = await User.db.find(session); - - return Response.ok( - body: Body.fromString( - jsonEncode(users.map((u) => u.toJson()).toList()), - mimeType: MimeType.json, - ), - ); - } - - Future _get(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final session = request.session; - final user = await User.db.findById(session, userId); - - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _create(Request request) async { - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = User(name: data['name'], email: data['email']); - await User.db.insertRow(session, user); - - return Response.created( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _update(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = await User.db.findById(session, userId); - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - user.name = data['name'] ?? user.name; - user.email = data['email'] ?? user.email; - await User.db.updateRow(session, user); - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _delete(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final session = request.session; - final deleted = await User.db.deleteRow(session, userId); - - if (!deleted) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - return Response.noContent(); - } - - // Required by Route but not used since we override injectIn - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire CRUD module under /api/users -pod.webServer.addRoute(UserCrudModule(), '/api/users'); -``` - -This creates the following RESTful endpoints: - -- `GET /api/users` - List all users -- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) -- `POST /api/users` - Create a new user -- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) -- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) - -:::tip Path parameters are accessed using symbols: -`request.pathParameters[#paramName]`. Always validate and parse these values -since they come from user input as strings. ::: - -#### Composing multiple modules - -You can create a parent module that composes multiple sub-modules: - -```dart -class ApiModule extends Route { - @override - void injectIn(RelicRouter router) { - // Inject sub-modules at different paths - router.group('/users').inject(UserCrudModule()); - router.group('/posts').inject(PostCrudModule()); - router.group('/comments').inject(CommentCrudModule()); - - // Add module-level routes - router.get('/health', _healthCheck); - } - - Future _healthCheck(Request request) async { - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), - mimeType: MimeType.json, - ), - ); - } - - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire API module -pod.webServer.addRoute(ApiModule(), '/api'); -``` - -This pattern enables you to: - -- **Organize routes hierarchically** - Group related functionality together -- **Reuse route modules** - Use the same module in different applications -- **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler - implementation - -:::tip When overriding `injectIn`, you typically don't need to implement -`handleCall` since you're registering handler functions directly with the -router. You can throw `UnimplementedError` in `handleCall` to make it clear the -method isn't used. ::: - -## Middleware - -Routes handle the core logic of your application, but many concerns cut across -multiple routes: logging every request, validating API keys, handling CORS -headers, or catching errors. Rather than duplicating this code in each route, -middleware lets you apply it globally or to specific path prefixes. - -Middleware functions are wrappers that sit between the incoming request and your -route handler. They can inspect or modify requests before they reach your -routes, and transform responses before they're sent back to the client. This -makes middleware perfect for authentication, logging, error handling, and any -other cross-cutting concern in your web server. - -### Adding middleware - -Use the `addMiddleware` method to apply middleware to specific path prefixes: - -```dart -// Apply to all routes -pod.webServer.addMiddleware(loggingMiddleware, '/'); - -// Apply only to API routes -pod.webServer.addMiddleware(authMiddleware, '/api'); -``` - -### Creating custom middleware - -Middleware is a function that takes a `Handler` and returns a new `Handler`. -Here's a simple logging middleware example: - -```dart -Handler loggingMiddleware(Handler innerHandler) { - return (Request request) async { - final start = DateTime.now(); - print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); - - // Call the next handler in the chain - final response = await innerHandler(request); - - final duration = DateTime.now().difference(start); - print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); - - return response; - }; -} -``` - -### API key validation middleware - -A common use case is validating API keys for protected routes: - -```dart -Handler apiKeyMiddleware(Handler innerHandler) { - return (Request request) async { - // Check for API key in header - final apiKey = request.headers['X-API-Key']?.firstOrNull; - - if (apiKey == null) { - return Response.unauthorized( - body: Body.fromString('API key required'), - ); - } - - // Verify API key - if (!await isValidApiKey(apiKey)) { - return Response.forbidden( - body: Body.fromString('Invalid API key'), - ); - } - - // Continue to the next handler - return await innerHandler(request); - }; -} - -// Apply to protected routes -pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); -``` - -:::info For user authentication, use Serverpod's built-in authentication system -which integrates with the `Session` object. The middleware examples here are for -additional web-specific validations like API keys, rate limiting, or request -validation. ::: - -### CORS middleware - -Enable Cross-Origin Resource Sharing for your API: - -```dart -Handler corsMiddleware(Handler innerHandler) { - return (Request request) async { - // Handle preflight requests - if (request.method == Method.options) { - return Response.ok( - headers: Headers.build((h) { - h.set('Access-Control-Allow-Origin', '*'); - h.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); - h.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - }), - ); - } - - // Process the request - final response = await innerHandler(request); - - // Add CORS headers to response - return response.change( - headers: Headers.build((h) { - h.set('Access-Control-Allow-Origin', '*'); - }), - ); - }; -} - -pod.webServer.addMiddleware(corsMiddleware, '/api'); -``` - -### Error handling - -Production applications need robust error handling. Rather than adding try-catch -blocks to every route, use error-handling middleware to catch exceptions -globally and return consistent error responses. - -#### Error-handling middleware - -Error-handling middleware wraps all your routes and catches any exceptions they -throw: - -```dart -Handler errorHandlingMiddleware(Handler innerHandler) { - return (Request request) async { - try { - return await innerHandler(request); - } on FormatException catch (e) { - // Handle JSON parsing errors - return Response.badRequest( - body: Body.fromString( - jsonEncode({'error': 'Invalid request format', 'message': e.message}), - mimeType: MimeType.json, - ), - ); - } catch (e, stackTrace) { - // Log the error - print('Error handling ${request.method} ${request.url.path}: $e'); - print(stackTrace); - - // Return a generic error response - return Response.internalServerError( - body: Body.fromString( - jsonEncode({'error': 'Internal server error'}), - mimeType: MimeType.json, - ), - ); - } - }; -} - -// Apply to all routes -pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); -``` - -With error-handling middleware in place, your route handlers can focus on -business logic without extensive try-catch blocks. The middleware catches common -exceptions like: - -- `FormatException` from `jsonDecode()` - Returns 400 Bad Request -- Database errors - Returns 500 Internal Server Error with logging -- Any other uncaught exceptions - Returns 500 with error details logged - -If an exception escapes all middleware, Serverpod will automatically return a -500 Internal Server Error response. However, using error-handling middleware -gives you control over error responses and logging. - -### Middleware execution order - -Middleware is applied based on path hierarchy, with more specific paths taking -precedence. Within the same path, middleware executes in the order it was -registered: - -```dart -pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) -pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes -pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes -``` - -For a request to `/api/users`, the execution order is: - -1. `loggingMiddleware` (before) -2. `authMiddleware` (before) -3. `rateLimitMiddleware` (before) -4. Your route handler -5. `rateLimitMiddleware` (after) -6. `authMiddleware` (after) -7. `loggingMiddleware` (after) - -### Request-scoped data with ContextProperty - -Middleware often needs to pass computed data to downstream handlers. For -example, a tenant identification middleware might extract the tenant ID from a -subdomain, or a logging middleware might generate a request ID for tracing. -Since `Request` objects are immutable, you can't just add properties to them. -This is where `ContextProperty` comes in. - -`ContextProperty` provides a type-safe way to attach data to a `Request` -object without modifying it. Think of it as a side channel for request-scoped -data that middleware can write to and routes can read from. The data is -automatically cleaned up when the request completes, making it perfect for -per-request state. - -:::info Note that Serverpod's `Route.handleCall()` already receives a `Session` -parameter which includes authenticated user information if available. Use -`ContextProperty` for web-specific request data that isn't part of the standard -Session, such as request IDs, feature flags, or API version information -extracted from headers. ::: - -#### Why use ContextProperty? - -Since `Request` objects are immutable, you can't modify them directly. -`ContextProperty` allows you to associate additional data with a request that -can be accessed by all downstream middleware and handlers. Common use cases -include: - -- **Request ID tracking** - Generated correlation IDs for distributed tracing - (purely request-scoped, not session data) -- **API versioning** - Extract and store API version from headers -- **Feature flags** - Request-specific toggles based on headers or A/B testing -- **Rate limiting** - Per-request rate limit state -- **Tenant identification** - Multi-tenant context from subdomains (when not - part of user session) - -#### Creating a ContextProperty - -Define a `ContextProperty` as a top-level constant or static field: - -```dart -// Define a property for request ID tracking -final requestIdProperty = ContextProperty(); - -// Define a property for tenant identification -final tenantProperty = ContextProperty(); - -// Optional: with a default value -final featureFlagsProperty = ContextProperty( - defaultValue: () => FeatureFlags.defaults(), -); -``` - -#### Setting values in middleware - -Middleware can set values on the context property, making them available to all -downstream handlers: - -```dart -final requestIdProperty = ContextProperty(); - -Handler requestIdMiddleware(Handler innerHandler) { - return (Request request) async { - // Generate a unique request ID for tracing - final requestId = Uuid().v4(); - - // Attach to request context - requestIdProperty[request] = requestId; - - // Log the incoming request - print('[$requestId] ${request.method} ${request.url.path}'); - - // Continue to next handler - final response = await innerHandler(request); - - // Log the response - print('[$requestId] Response: ${response.statusCode}'); - - // Optionally add request ID to response headers - return response.change( - headers: {'X-Request-ID': requestId}, - ); - }; -} -``` - -#### Accessing values in routes - -Route handlers can retrieve the value from the context property: - -```dart -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Get the request ID from context - final requestId = requestIdProperty[request]; - - // Use it for logging or tracing - session.log('Processing request $requestId'); - - // Your route logic here - final data = await processRequest(session); - - return Response.ok( - body: Body.fromString( - jsonEncode(data), - mimeType: MimeType.json, - ), - ); - } -} -``` - -#### Safe access with getOrNull - -If a value might not be set, use `getOrNull()` to avoid exceptions: - -```dart -class TenantRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Safely get tenant, returns null if not set - final tenant = tenantProperty.getOrNull(request); - - if (tenant != null) { - // Fetch tenant-specific data - final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); - return Response.ok( - body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), - ); - } else { - return Response.badRequest( - body: Body.fromString('Missing tenant identifier'), - ); - } - } -} -``` - -#### Complete multi-tenant example - -Here's a complete example showing tenant identification from subdomains: - -```dart -// Define the context property for tenant ID -final tenantProperty = ContextProperty(); - -// Tenant identification middleware (extracts from subdomain) -Handler tenantMiddleware(Handler innerHandler) { - return (Request request) async { - final host = request.headers.host; - - if (host == null) { - return Response.badRequest( - body: Body.fromString('Missing host header'), - ); - } - - // Extract tenant from subdomain (e.g., acme.example.com -> "acme") - final parts = host.host.split('.'); - if (parts.length < 2) { - return Response.badRequest( - body: Body.fromString('Invalid hostname format'), - ); - } - - final tenant = parts.first; - - // Validate tenant exists (implement your own logic) - final session = request.session; - final tenantExists = await validateTenant(session, tenant); - - if (!tenantExists) { - return Response.notFound( - body: Body.fromString('Tenant not found'), - ); - } - - // Attach tenant to context - tenantProperty[request] = tenant; - - return await innerHandler(request); - }; -} - -// Usage in your server -pod.webServer.addMiddleware(tenantMiddleware, '/'); - -// Routes automatically have access to the tenant -class TenantDataRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final tenant = tenantProperty[request]; - - // Fetch tenant-specific data - final data = await session.db.find( - where: (p) => p.tenantId.equals(tenant), - ); - - return Response.ok( - body: Body.fromString( - jsonEncode(data.map((p) => p.toJson()).toList()), - mimeType: MimeType.json, - ), - ); - } -} -``` - -#### Multiple context properties - -You can use multiple context properties for different types of data: - -```dart -final requestIdProperty = ContextProperty(); -final tenantProperty = ContextProperty(); -final apiVersionProperty = ContextProperty(); - -Handler requestContextMiddleware(Handler innerHandler) { - return (Request request) async { - // Generate and attach request ID - final requestId = Uuid().v4(); - requestIdProperty[request] = requestId; - - // Extract tenant from subdomain or header - final host = request.headers.host; - if (host != null) { - final tenant = host.host.split('.').first; - tenantProperty[request] = tenant; - } - - // Extract API version from header - final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; - apiVersionProperty[request] = apiVersion; - - return await innerHandler(request); - }; -} - -// Later in your route -class DataRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final requestId = requestIdProperty[request]; - final tenant = tenantProperty[request]; - final apiVersion = apiVersionProperty[request]; - - session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); - - // Fetch tenant-specific data - final data = await fetchTenantData(session, tenant); - - return Response.ok( - body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), - ); - } -} -``` - -:::tip Best Practices - -- Define `ContextProperty` instances as top-level constants or static fields -- Use descriptive names for your properties (e.g., `requestIdProperty`, not just - `requestId`) -- Use `getOrNull()` when the value might not be set -- Set properties in middleware, not in routes -- Use specific types for better type safety ::: - -### Built-in logging middleware - -Serverpod re-exports Relic's built-in `logRequests()` middleware for convenient -request logging: - -```dart -import 'package:serverpod/serverpod.dart'; - -pod.webServer.addMiddleware(logRequests(), '/'); -``` - -This logs all requests with method, path, status code, and response time. - -## Typed headers - -HTTP headers are traditionally accessed as strings, which means you need to -manually parse values, handle edge cases, and validate formats. Serverpod's web -server (via Relic) provides a better approach: typed headers that automatically -parse header values into strongly-typed Dart objects. - -For example, instead of parsing `Authorization: Bearer abc123` as a string and -extracting the token yourself, you can access `request.headers.authorization` to -get a `BearerAuthorizationHeader` object with the token already parsed and -validated. This eliminates boilerplate code, catches errors early, and makes -your code more maintainable. - -### Reading typed headers - -Access typed headers through extension methods on `request.headers`: - -```dart -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Type-safe accessors return parsed objects or null - final auth = request.headers.authorization; // AuthorizationHeader? - final contentType = request.headers.contentType; // ContentTypeHeader? - final cookies = request.headers.cookie; // CookieHeader? - final userAgent = request.headers.userAgent; // String? - final host = request.headers.host; // HostHeader? - - // Raw string access is also available for any header - final authRaw = request.headers['Authorization']; // Iterable? - final custom = request.headers['X-Custom-Header']; // Iterable? - - return Response.ok(); - } -} -``` - -Common request headers include: - -- `authorization` - AuthorizationHeader (Bearer/Basic/Digest) -- `contentType` - ContentTypeHeader -- `contentLength` - int -- `cookie` - CookieHeader -- `accept` - AcceptHeader (media types) -- `acceptEncoding` - AcceptEncodingHeader -- `acceptLanguage` - AcceptLanguageHeader -- `userAgent` - String -- `host` - HostHeader -- `referer` - Uri -- `origin` - Uri - -### Setting typed headers - -Set typed headers in responses using the `Headers.build()` builder pattern: - -```dart -return Response.ok( - headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 3600, - publicCache: true, - ); - h.contentType = ContentTypeHeader( - mimeType: MimeType.json, - charset: 'utf-8', - ); - - // Set custom headers (raw) - h['X-API-Version'] = ['2.0']; - }), - body: Body.fromString(jsonEncode(data)), -); -``` - -Common response headers include: - -- `cacheControl` - CacheControlHeader -- `setCookie` - SetCookieHeader -- `location` - Uri -- `contentDisposition` - ContentDispositionHeader -- `etag` - ETagHeader -- `vary` - VaryHeader - -### AuthorizationHeader - Authentication - -The `AuthorizationHeader` supports three authentication schemes: - -**Bearer Token (JWT, OAuth):** - -```dart -final auth = request.headers.authorization; - -if (auth is BearerAuthorizationHeader) { - final token = auth.token; // The actual token string - - // Validate token - if (!await validateToken(token)) { - return Response.unauthorized(); - } -} -``` - -**Basic Authentication:** - -```dart -if (auth is BasicAuthorizationHeader) { - final username = auth.username; - final password = auth.password; - - // Validate credentials - if (!await validateCredentials(username, password)) { - return Response.unauthorized(); - } -} -``` - -**Setting Bearer token:** - -```dart -headers: Headers.build((h) { - h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); -}), -``` - -### CacheControlHeader - Cache directives - -Control caching behavior with type-safe cache directives: - -```dart -// Public cache with 1 hour expiration -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 3600, // Cache for 1 hour - publicCache: true, // Shared cache allowed - mustRevalidate: true, // Must revalidate after expiry - staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating - ); -}), -``` - -```dart -// Secure defaults for sensitive data -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - noStore: true, // Don't store anywhere - noCache: true, // Must revalidate - privateCache: true, // Only private cache - ); -}), -``` - -Available directives: - -- `noCache`, `noStore` - Cache control flags -- `maxAge`, `sMaxAge` - Seconds of freshness -- `mustRevalidate`, `proxyRevalidate` - Revalidation requirements -- `publicCache`, `privateCache` - Cache scope -- `staleWhileRevalidate`, `staleIfError` - Stale caching -- `immutable` - Content never changes - -### ContentDispositionHeader - File downloads - -Specify how content should be handled (inline display or download): - -```dart -// File download with proper filename -headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter(name: 'filename', value: 'report.pdf'), - ], - ); -}), -``` - -```dart -// With extended encoding (RFC 5987) for non-ASCII filenames -h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: 'rapport.pdf', - isExtended: true, - encoding: 'UTF-8', - ), - ], -); -``` - -### CookieHeader and SetCookieHeader - Cookies - -**Reading cookies from requests:** - -```dart -final cookieHeader = request.headers.cookie; - -if (cookieHeader != null) { - // Find a specific cookie - final sessionId = cookieHeader.getCookie('session_id')?.value; - - // Iterate all cookies - for (final cookie in cookieHeader.cookies) { - print('${cookie.name}=${cookie.value}'); - } -} -``` - -**Setting cookies in responses:** - -```dart -headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: '12345abcde', - maxAge: 3600, // 1 hour - path: Uri.parse('/'), - domain: Uri.parse('example.com'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.strict, // CSRF protection - ); -}), -``` - -SameSite values: - -- `SameSite.lax` - Default, not sent on cross-site requests (except navigation) -- `SameSite.strict` - Never sent on cross-site requests -- `SameSite.none` - Sent on all requests (requires `secure: true`) - -### Complete examples - -**Secure API with authentication and caching:** - -```dart -class SecureApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Check authorization - final auth = request.headers.authorization; - if (auth is! BearerAuthorizationHeader) { - return Response.unauthorized(); - } - - // Validate token - if (!await validateToken(auth.token)) { - return Response.forbidden(); - } - - // Return data with cache headers - return Response.ok( - headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 300, // 5 minutes - publicCache: true, - mustRevalidate: true, - ); - h.contentType = ContentTypeHeader( - mimeType: MimeType.json, - charset: 'utf-8', - ); - }), - body: Body.fromString(jsonEncode(data)), - ); - } -} -``` - -**File download with proper headers:** - -```dart -class DownloadRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final fileId = request.pathParameters[#fileId]; - final file = await getFile(session, fileId); - - return Response.ok( - headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: file.name, - isExtended: true, - encoding: 'UTF-8', - ), - ], - ); - h.contentType = ContentTypeHeader( - mimeType: file.mimeType, - ); - h.cacheControl = CacheControlHeader( - noCache: true, - mustRevalidate: true, - ); - }), - body: Body.fromBytes(file.content), - ); - } -} -``` - -**Cookie-based sessions:** - -```dart -class LoginRoute extends Route { - LoginRoute() : super(methods: {Method.post}); - - @override - Future handleCall(Session session, Request request) async { - // Authenticate user... - final sessionToken = await authenticateAndCreateSession(session, request); - - return Response.ok( - headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: sessionToken, - maxAge: 86400, // 24 hours - path: Uri.parse('/'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.lax, // CSRF protection - ); - }), - body: Body.fromString( - jsonEncode({'status': 'logged_in'}), - mimeType: MimeType.json, - ), - ); - } -} -``` - -:::tip Best Practices - -- Use typed headers for automatic parsing and validation -- Set appropriate cache headers for better performance -- Use `secure: true` and `httpOnly: true` for sensitive cookies -- Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection ::: - -### Creating custom typed headers - -While Relic provides typed headers for all standard HTTP headers, your -application may use custom headers for API versioning, feature flags, or -application-specific metadata. Rather than falling back to string-based access -for these custom headers, you can create your own typed headers using the same -pattern Relic uses internally. - -Creating a custom typed header involves three steps: defining the header class -with parsing logic, creating a codec for serialization, and setting up a -`HeaderAccessor` for type-safe access. Once configured, your custom headers work -just like the built-in ones, with automatic parsing, validation, and convenient -property-style access. - -Here's a complete example for a custom `X-API-Version` header: - -```dart -// Define your typed header class -final class ApiVersionHeader { - final int major; - final int minor; - - ApiVersionHeader({required this.major, required this.minor}); - - // Parse from string format "1.2" - factory ApiVersionHeader.parse(String value) { - final parts = value.split('.'); - if (parts.length != 2) { - throw const FormatException('Invalid API version format'); - } - - final major = int.tryParse(parts[0]); - final minor = int.tryParse(parts[1]); - - if (major == null || minor == null) { - throw const FormatException('Invalid API version numbers'); - } - - return ApiVersionHeader(major: major, minor: minor); - } - - // Encode to string format - String encode() => '$major.$minor'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ApiVersionHeader && - major == other.major && - minor == other.minor; - - @override - int get hashCode => Object.hash(major, minor); - - @override - String toString() => 'ApiVersionHeader($major.$minor)'; -} - -// Create a HeaderCodec for encoding/decoding -const _apiVersionCodec = HeaderCodec.single( - ApiVersionHeader.parse, - (ApiVersionHeader value) => [value.encode()], -); - -// Create a global HeaderAccessor -const apiVersionHeader = HeaderAccessor( - 'x-api-version', - _apiVersionCodec, -); -``` - -**Using your custom header:** - -```dart -// Reading the header -class ApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final version = apiVersionHeader[request.headers](); - - if (version != null && version.major < 2) { - return Response.badRequest( - body: Body.fromString('API version 2.0 or higher required'), - ); - } - - return Response.ok(); - } -} - -// Setting the header -return Response.ok( - headers: Headers.build((h) { - apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); - }), -); -``` - -**Multi-value header example:** - -For headers that can have multiple comma-separated values: - -```dart -final class CustomTagsHeader { - final List tags; - - CustomTagsHeader({required List tags}) - : tags = List.unmodifiable(tags); - - // Parse from multiple values or comma-separated - factory CustomTagsHeader.parse(Iterable values) { - final allTags = values - .expand((v) => v.split(',')) - .map((t) => t.trim()) - .where((t) => t.isNotEmpty) - .toSet() - .toList(); - - if (allTags.isEmpty) { - throw const FormatException('Tags cannot be empty'); - } - - return CustomTagsHeader(tags: allTags); - } - - List encode() => [tags.join(', ')]; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CustomTagsHeader && - const ListEquality().equals(tags, other.tags); - - @override - int get hashCode => const ListEquality().hash(tags); -} - -// Use HeaderCodec (not HeaderCodec.single) for multi-value -const _customTagsCodec = HeaderCodec( - CustomTagsHeader.parse, - (CustomTagsHeader value) => value.encode(), -); - -const customTagsHeader = HeaderAccessor( - 'x-custom-tags', - _customTagsCodec, -); -``` - -**Optional: Add extension methods for convenient access** - -For better ergonomics, you can add extension methods to access your custom -headers with property syntax: - -```dart -extension CustomHeadersEx on Headers { - ApiVersionHeader? get apiVersion => apiVersionHeader[this](); - CustomTagsHeader? get customTags => customTagsHeader[this](); -} - -extension CustomMutableHeadersEx on MutableHeaders { - set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); - set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); -} -``` - -Now you can use property syntax instead of the bracket notation: - -```dart -// Reading with property syntax -final version = request.headers.apiVersion; -final tags = request.headers.customTags; - -// Setting with property syntax -return Response.ok( - headers: Headers.build((h) { - h.apiVersion = ApiVersionHeader(major: 2, minor: 1); - h.customTags = CustomTagsHeader(tags: ['production', 'v2']); - }), -); -``` - -**Key points:** - -- Use `HeaderCodec.single()` when your header has only one value -- Use `HeaderCodec()` when your header can have multiple comma-separated values -- Define the `HeaderAccessor` as a global `const` -- Throw `FormatException` for invalid header values -- Implement `==` and `hashCode` for value equality -- The `HeaderAccessor` automatically caches parsed values for performance -- Optionally add extension methods for convenient property-style access diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md new file mode 100644 index 00000000..813db558 --- /dev/null +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -0,0 +1,154 @@ +# Web Server Overview + +In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with third-party services. + +Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. + +## Your First Route + +When you create a new Serverpod project, it sets up a web server by default. Here's how to add a simple API endpoint: + +```dart +import 'package:serverpod/serverpod.dart'; + +class HelloRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from Serverpod!'}), + mimeType: MimeType.json, + ), + ); + } +} +``` + +Register the route in your `server.dart` file before starting the server: + +```dart +pod.webServer.addRoute(HelloRoute(), '/api/hello'); +await pod.start(); +``` + +Visit `http://localhost:8080/api/hello` to see your API response. + +## Core Concepts + +### Routes and Handlers + +A **Route** is a destination in your web server that handles requests and generates responses. Routes extend the `Route` base class and implement the `handleCall()` method: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Your logic here + return Response.ok(); + } +} +``` + +The `handleCall()` method receives: +- **Session** - Access to your database, logging, and authenticated user +- **Request** - The HTTP request with headers, body, and URL information + +### Response Types + +Return different response types based on your needs: + +```dart +// Success responses +return Response.ok(body: Body.fromString('Success')); +return Response.created(body: Body.fromString('Created')); +return Response.noContent(); + +// Error responses +return Response.badRequest(body: Body.fromString('Invalid input')); +return Response.unauthorized(body: Body.fromString('Not authenticated')); +return Response.notFound(body: Body.fromString('Not found')); +return Response.internalServerError(body: Body.fromString('Server error')); +``` + +### Adding Routes + +Routes are added with a path pattern: + +```dart +// Exact path +pod.webServer.addRoute(UserRoute(), '/api/users'); + +// Path with wildcard +pod.webServer.addRoute(StaticRoute.directory(Directory('web')), '/static/**'); +``` + +Routes are matched in the order they were added. + +## When to Use What + +### REST APIs → Custom Routes +For REST APIs, webhooks, or custom HTTP handlers, use custom `Route` classes: + +```dart +class UsersApiRoute extends Route { + UsersApiRoute() : super(methods: {Method.get, Method.post}); + + @override + Future handleCall(Session session, Request request) async { + if (request.method == Method.get) { + // List users + } else { + // Create user + } + } +} +``` + +See [Routing](routing) for details. + +### Static Files → StaticRoute +For serving CSS, JavaScript, images, or other static assets: + +```dart +pod.webServer.addRoute( + StaticRoute.directory(Directory('web/static')), + '/static/**', +); +``` + +See [Static Files](static-files) for cache-busting and optimization. + +### HTML Pages → External Frameworks +For server-side HTML rendering, consider integrating with [Jaspr](https://docs.page/schultek/jaspr) rather than using Serverpod's built-in HTML widgets. See [Server-Side HTML](server-side-html) for basic widget usage. + +## Database Access + +The `Session` parameter gives you full access to your Serverpod database: + +```dart +class UserRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Query database + final users = await User.db.find(session); + + // Use logging + session.log('Retrieved ${users.length} users'); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } +} +``` + +## Next Steps + +- **[Routing](routing)** - Learn about HTTP methods, path parameters, and wildcards +- **[Modular Routes](modular-routes)** - Organize related endpoints with `injectIn()` +- **[Middleware](middleware)** - Add cross-cutting functionality like error handling and logging +- **[Static Files](static-files)** - Serve static assets with cache-busting +- **[Typed Headers](typed-headers)** - Work with HTTP headers in a type-safe way diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md new file mode 100644 index 00000000..20b20184 --- /dev/null +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -0,0 +1,217 @@ +# Routing + +Routes are the foundation of your web server, directing incoming HTTP requests +to the right handlers. While simple routes work well for basic APIs, Serverpod +provides powerful routing features for complex applications: HTTP method +filtering, path parameters, wildcards, and fallback handling. Understanding +these patterns helps you build clean, maintainable APIs. + +## Custom Route classes + +While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base +class gives you complete control over request handling. By extending `Route` and +implementing `handleCall()`, you can build REST APIs, serve files, or handle any +custom HTTP interaction. This is ideal when you need to work directly with +request bodies, headers, and response formats. + +```dart +class ApiRoute extends Route { + ApiRoute() : super(methods: {Method.get, Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Access request method + if (request.method == Method.post) { + // Read request body + final body = await request.readAsString(); + final data = jsonDecode(body); + + // Process and return JSON response + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'success', 'data': data}), + mimeType: MimeType.json, + ), + ); + } + + // Return data for GET requests + return Response.ok( + body: Body.fromString( + jsonEncode({'message': 'Hello from API'}), + mimeType: MimeType.json, + ), + ); + } +} + +// Register the route +pod.webServer.addRoute(ApiRoute(), '/api/data'); +``` + +:::info + +The examples in this documentation omit error handling for brevity. See the +Error Handling section in Middleware below for the recommended approach using +global error-handling middleware. + +::: + +## HTTP methods + +Routes can specify which HTTP methods they respond to using the `methods` +parameter. The available methods are: + +- `Method.get` - Retrieve data +- `Method.post` - Create new resources +- `Method.put` - Update resources (full replacement) +- `Method.patch` - Update resources (partial) +- `Method.delete` - Delete resources +- `Method.head` - Same as GET but without response body +- `Method.options` - Query supported methods (used for CORS) + +```dart +class UserRoute extends Route { + UserRoute() : super( + methods: {Method.get, Method.post, Method.delete}, + ); + + @override + Future handleCall(Session session, Request request) async { + switch (request.method) { + case Method.get: + return await _getUser(request); + case Method.post: + return await _createUser(request); + case Method.delete: + return await _deleteUser(request); + default: + return Response.methodNotAllowed(); + } + } +} +``` + +## Path parameters + +Routes support named path parameters using the `:paramName` syntax. These are +automatically extracted and made available through the `Request` object: + +```dart +class UserRoute extends Route { + UserRoute() : super(methods: {Method.get}); + + @override + void injectIn(RelicRouter router) { + // Define route with path parameter + router.get('/:id', asHandler); + } + + @override + Future handleCall(Session session, Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound(); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } +} + +// Register at /api/users - will match /api/users/123 +pod.webServer.addRoute(UserRoute(), '/api/users'); +``` + +You can use multiple path parameters in a single route: + +```dart +router.get('/:userId/posts/:postId', handler); +// Matches: /123/posts/456 +// request.pathParameters[#userId] => '123' +// request.pathParameters[#postId] => '456' +``` + +## Wildcards + +Routes also support wildcard matching for catching all paths: + +```dart +// Single-level wildcard - matches /item/foo but not /item/foo/bar +pod.webServer.addRoute(ItemRoute(), '/item/*'); + +// Tail-match wildcard - matches /item/foo and /item/foo/bar/baz +pod.webServer.addRoute(ItemRoute(), '/item/**'); +``` + +:::info Performance Guarantee + +The `/**` wildcard is a **tail-match** pattern and can only appear at the end of +a route path (e.g., `/static/**`). Patterns like `/a/**/b` are not supported. +This design ensures O(h) route lookup performance, where h is the path length, +without backtracking. This keeps routing fast and predictable, even with many +routes. + +::: + +Access the matched path information through the `Request` object: + +```dart +@override +Future handleCall(Session session, Request request) async { + // Get the remaining path after the route prefix + final remainingPath = request.url.path; + + // Access query parameters + final query = request.url.queryParameters['query']; + + return Response.ok( + body: Body.fromString('Path: $remainingPath, Query: $query'), + ); +} +``` + +## Fallback routes + +You can set a fallback route that handles requests when no other route matches: + +```dart +class NotFoundRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + return Response.notFound( + body: Body.fromString('Page not found: ${request.url.path}'), + ); + } +} + +// Set as fallback +pod.webServer.fallbackRoute = NotFoundRoute(); +``` + +## Next Steps + +- Learn about [modular routes](modular-routes) for organizing complex APIs +- Add [middleware](middleware) for request processing and error handling +- Serve [static files](static-files) like images and assets diff --git a/docs/06-concepts/18-webserver/03-modular-routes.md b/docs/06-concepts/18-webserver/03-modular-routes.md new file mode 100644 index 00000000..150d2f99 --- /dev/null +++ b/docs/06-concepts/18-webserver/03-modular-routes.md @@ -0,0 +1,265 @@ +# Modular Routes + +As your web server grows, managing dozens of individual route registrations can +become unwieldy. Modular routes solve this by letting you group related +endpoints into reusable modules. For example, you might create a +`UserCrudModule` that handles all user-related endpoints (`GET /users`, +`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. + +The key to modular routes is the `injectIn()` method. When you call +`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` +on a router group for that path. By overriding `injectIn()`, you can register +multiple handler functions instead of implementing a single `handleCall()` +method. This pattern is perfect for REST resources, API modules, or any group of +related endpoints. + +## Session Access in Modular Routes + +When using `injectIn()` with handler functions (`router.get('/', _handler)`), +your handlers receive only a `Request` parameter. To access the `Session`, use +`request.session`: + +```dart +Future _handler(Request request) async { + final session = request.session; // Extract Session from Request + // ... use session +} +``` + +This differs from `Route.handleCall()` which receives both Session and Request +as explicit parameters. The modular route pattern uses Relic's router directly, +which only provides Request to handlers. + +## Creating a CRUD module + +Here's an example of a modular CRUD route that registers multiple endpoints with +path parameters: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get) + ..post('/', _create) + ..put('/:id', _update) + ..delete('/:id', _delete); + } + + // Handler methods + Future _list(Request request) async { + final session = request.session; + final users = await User.db.find(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } + + Future _get(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _create(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = User(name: data['name'], email: data['email']); + await User.db.insertRow(session, user); + + return Response.created( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _update(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = await User.db.findById(session, userId); + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + user.name = data['name'] ?? user.name; + user.email = data['email'] ?? user.email; + await User.db.updateRow(session, user); + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _delete(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final deleted = await User.db.deleteRow(session, userId); + + if (!deleted) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.noContent(); + } + + // Required by Route but not used since we override injectIn + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire CRUD module under /api/users +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` + +This creates the following RESTful endpoints: + +- `GET /api/users` - List all users +- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) +- `POST /api/users` - Create a new user +- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) +- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) + +:::tip + +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. +Always validate and parse these values since they come from user input as +strings. + +::: + +## Composing multiple modules + +You can create a parent module that composes multiple sub-modules: + +```dart +class ApiModule extends Route { + @override + void injectIn(RelicRouter router) { + // Inject sub-modules at different paths + router.group('/users').inject(UserCrudModule()); + router.group('/posts').inject(PostCrudModule()); + router.group('/comments').inject(CommentCrudModule()); + + // Add module-level routes + router.get('/health', _healthCheck); + } + + Future _healthCheck(Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), + mimeType: MimeType.json, + ), + ); + } + + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire API module +pod.webServer.addRoute(ApiModule(), '/api'); +``` + +This pattern enables you to: + +- **Organize routes hierarchically** - Group related functionality together +- **Reuse route modules** - Use the same module in different applications +- **Compose complex APIs** - Build large APIs from smaller, focused modules +- **Separate concerns** - Keep route registration logic separate from handler + implementation + +:::tip + +When overriding `injectIn`, you typically don't need to implement `handleCall` +since you're registering handler functions directly with the router. You can +throw `UnimplementedError` in `handleCall` to make it clear the method isn't +used. + +::: + +## Next Steps + +- Add [middleware](middleware) for cross-cutting concerns like logging and + error handling +- Learn about [typed headers](typed-headers) for type-safe header access +- Explore [static file serving](static-files) for assets and downloads diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md new file mode 100644 index 00000000..729066ed --- /dev/null +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -0,0 +1,472 @@ +# Middleware + +Routes handle the core logic of your application, but many concerns cut across +multiple routes: logging every request, validating API keys, handling CORS +headers, or catching errors. Rather than duplicating this code in each route, +middleware lets you apply it globally or to specific path prefixes. + +Middleware functions are wrappers that sit between the incoming request and your +route handler. They can inspect or modify requests before they reach your +routes, and transform responses before they're sent back to the client. This +makes middleware perfect for authentication, logging, error handling, and any +other cross-cutting concern in your web server. + +## Adding middleware + +Use the `addMiddleware` method to apply middleware to specific path prefixes: + +```dart +// Apply to all routes +pod.webServer.addMiddleware(loggingMiddleware, '/'); + +// Apply only to API routes +pod.webServer.addMiddleware(authMiddleware, '/api'); +``` + +## Creating custom middleware + +Middleware is a function that takes a `Handler` and returns a new `Handler`. +Here's a simple logging middleware example: + +```dart +Handler loggingMiddleware(Handler innerHandler) { + return (Request request) async { + final start = DateTime.now(); + print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); + + // Call the next handler in the chain + final response = await innerHandler(request); + + final duration = DateTime.now().difference(start); + print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); + + return response; + }; +} +``` + +## API key validation middleware + +A common use case is validating API keys for protected routes: + +```dart +Handler apiKeyMiddleware(Handler innerHandler) { + return (Request request) async { + // Check for API key in header + final apiKey = request.headers['X-API-Key']?.firstOrNull; + + if (apiKey == null) { + return Response.unauthorized( + body: Body.fromString('API key required'), + ); + } + + // Verify API key + if (!await isValidApiKey(apiKey)) { + return Response.forbidden( + body: Body.fromString('Invalid API key'), + ); + } + + // Continue to the next handler + return await innerHandler(request); + }; +} + +// Apply to protected routes +pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); +``` + +:::info + +For user authentication, use Serverpod's built-in authentication system which +integrates with the `Session` object. The middleware examples here are for +additional web-specific validations like API keys, rate limiting, or request +validation. + +::: + +## CORS middleware + +Enable Cross-Origin Resource Sharing for your API: + +```dart +Handler corsMiddleware(Handler innerHandler) { + return (Request request) async { + // Handle preflight requests + if (request.method == Method.options) { + return Response.ok( + headers: Headers.build((h) { + h.accessControlAllowOrigin = const AccessControlAllowOriginHeader.wildcard(); + h.accessControlAllowMethods = AccessControlAllowMethodsHeader.methods( + methods: [Method.get, Method.post, Method.put, Method.delete], + ); + h.accessControlAllowHeaders = AccessControlAllowHeadersHeader.headers( + headers: ['Content-Type', 'Authorization'], + ); + }), + ); + } + + // Process the request + final response = await innerHandler(request); + + // Add CORS headers to response + return response.copyWith( + headers: response.headers.transform((h) { + h.accessControlAllowOrigin = const AccessControlAllowOriginHeader.wildcard(); + }), + ); + }; +} + +pod.webServer.addMiddleware(corsMiddleware, '/api'); +``` + +## Error handling + +Production applications need robust error handling. Rather than adding try-catch +blocks to every route, use error-handling middleware to catch exceptions +globally and return consistent error responses. + +### Error-handling middleware + +Error-handling middleware wraps all your routes and catches any exceptions they +throw: + +```dart +Handler errorHandlingMiddleware(Handler innerHandler) { + return (Request request) async { + try { + return await innerHandler(request); + } on FormatException catch (e) { + // Handle JSON parsing errors + return Response.badRequest( + body: Body.fromString( + jsonEncode({'error': 'Invalid request format', 'message': e.message}), + mimeType: MimeType.json, + ), + ); + } catch (e, stackTrace) { + // Log the error + print('Error handling ${request.method} ${request.url.path}: $e'); + print(stackTrace); + + // Return a generic error response + return Response.internalServerError( + body: Body.fromString( + jsonEncode({'error': 'Internal server error'}), + mimeType: MimeType.json, + ), + ); + } + }; +} + +// Apply to all routes +pod.webServer.addMiddleware(errorHandlingMiddleware, '/'); +``` + +With error-handling middleware in place, your route handlers can focus on +business logic without extensive try-catch blocks. The middleware catches common +exceptions like: + +- `FormatException` from `jsonDecode()` - Returns 400 Bad Request +- Database errors - Returns 500 Internal Server Error with logging +- Any other uncaught exceptions - Returns 500 with error details logged + +If an exception escapes all middleware, Serverpod will automatically return a +500 Internal Server Error response. However, using error-handling middleware +gives you control over error responses and logging. + +## Middleware execution order + +Middleware is applied based on path hierarchy, with more specific paths taking +precedence. Within the same path, middleware executes in the order it was +registered: + +```dart +pod.webServer.addMiddleware(loggingMiddleware, '/'); // Executes first (outer) +pod.webServer.addMiddleware(authMiddleware, '/api'); // Executes second (inner) for /api routes +pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (innermost) for /api routes +``` + +For a request to `/api/users`, the execution order is: + +1. `loggingMiddleware` (before) +2. `authMiddleware` (before) +3. `rateLimitMiddleware` (before) +4. Your route handler +5. `rateLimitMiddleware` (after) +6. `authMiddleware` (after) +7. `loggingMiddleware` (after) + +## Request-scoped data with ContextProperty + +Middleware often needs to pass computed data to downstream handlers. For +example, a tenant identification middleware might extract the tenant ID from a +subdomain, or a logging middleware might generate a request ID for tracing. +Since `Request` objects are immutable, you can't just add properties to them. +This is where `ContextProperty` comes in. + +`ContextProperty` provides a type-safe way to attach data to a `Request` +object without modifying it. Think of it as a side channel for request-scoped +data that middleware can write to and routes can read from. The data is +automatically cleaned up when the request completes, making it perfect for +per-request state. + +:::info + +Note that Serverpod's `Route.handleCall()` already receives a `Session` parameter +which includes authenticated user information if available. Use `ContextProperty` +for web-specific request data that isn't part of the standard Session, such as +request IDs, feature flags, or API version information extracted from headers. + +::: + +### Why use ContextProperty? + +Since `Request` objects are immutable, you can't modify them directly. +`ContextProperty` allows you to associate additional data with a request that +can be accessed by all downstream middleware and handlers. Common use cases +include: + +- **Request ID tracking** - Generated correlation IDs for distributed tracing + (purely request-scoped, not session data) +- **API versioning** - Extract and store API version from headers +- **Feature flags** - Request-specific toggles based on headers or A/B testing +- **Rate limiting** - Per-request rate limit state +- **Tenant identification** - Multi-tenant context from subdomains (when not + part of user session) + +### Creating a ContextProperty + +Define a `ContextProperty` as a top-level constant or static field: + +```dart +// Define a property for request ID tracking +final requestIdProperty = ContextProperty('requestId'); + +// Define a property for tenant identification +final tenantProperty = ContextProperty('tenant'); + +// Optional: with a default value +final featureFlagsProperty = ContextProperty( + 'featureFlags', + defaultValue: () => FeatureFlags.defaults(), +); +``` + +### Setting values in middleware + +Middleware can set values on the context property, making them available to all +downstream handlers: + +```dart +final requestIdProperty = ContextProperty('requestId'); + +Handler requestIdMiddleware(Handler innerHandler) { + return (Request request) async { + // Generate a unique request ID for tracing + final requestId = Uuid().v4(); + + // Attach to request context + requestIdProperty[request] = requestId; + + // Log the incoming request + print('[$requestId] ${request.method} ${request.url.path}'); + + // Continue to next handler + final response = await innerHandler(request); + + // Log the response + print('[$requestId] Response: ${response.statusCode}'); + + // Optionally add request ID to response headers + return response.copyWith( + headers: response.headers.transform((h) { + h['X-Request-ID'] = [requestId]; + }), + ); + }; +} +``` + +### Accessing values in routes + +Route handlers can retrieve the value from the context property: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Get the request ID from context + final requestId = requestIdProperty[request]; + + // Use it for logging or tracing + session.log('Processing request $requestId'); + + // Your route logic here + final data = await processRequest(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(data), + mimeType: MimeType.json, + ), + ); + } +} +``` + +### Safe access with getOrNull + +If a value might not be set, use `getOrNull()` to avoid exceptions: + +```dart +class TenantRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Safely get tenant, returns null if not set + final tenant = tenantProperty.getOrNull(request); + + if (tenant != null) { + // Fetch tenant-specific data + final data = await session.db.find(where: (t) => t.tenantId.equals(tenant)); + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } else { + return Response.badRequest( + body: Body.fromString('Missing tenant identifier'), + ); + } + } +} +``` + +### Complete multi-tenant example + +Here's a complete example showing tenant identification from subdomains: + +```dart +// Define the context property for tenant ID +final tenantProperty = ContextProperty('tenant'); + +// Tenant identification middleware (extracts from subdomain) +Handler tenantMiddleware(Handler innerHandler) { + return (Request request) async { + final host = request.headers.host; + + if (host == null) { + return Response.badRequest( + body: Body.fromString('Missing host header'), + ); + } + + // Extract tenant from subdomain (e.g., acme.example.com -> "acme") + final parts = host.host.split('.'); + if (parts.length < 2) { + return Response.badRequest( + body: Body.fromString('Invalid hostname format'), + ); + } + + final tenant = parts.first; + + // Validate tenant exists (implement your own logic) + final session = request.session; + final tenantExists = await validateTenant(session, tenant); + + if (!tenantExists) { + return Response.notFound( + body: Body.fromString('Tenant not found'), + ); + } + + // Attach tenant to context + tenantProperty[request] = tenant; + + return await innerHandler(request); + }; +} + +// Usage in your server +pod.webServer.addMiddleware(tenantMiddleware, '/'); + +// Routes automatically have access to the tenant +class TenantDataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final tenant = tenantProperty[request]; + + // Fetch tenant-specific data + final data = await session.db.find( + where: (p) => p.tenantId.equals(tenant), + ); + + return Response.ok( + body: Body.fromString( + jsonEncode(data.map((p) => p.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } +} +``` + +### Multiple context properties + +You can use multiple context properties for different types of data: + +```dart +final requestIdProperty = ContextProperty('requestId'); +final tenantProperty = ContextProperty('tenant'); +final apiVersionProperty = ContextProperty('apiVersion'); + +Handler requestContextMiddleware(Handler innerHandler) { + return (Request request) async { + // Generate and attach request ID + final requestId = Uuid().v4(); + requestIdProperty[request] = requestId; + + // Extract tenant from subdomain or header + final host = request.headers.host; + if (host != null) { + final tenant = host.host.split('.').first; + tenantProperty[request] = tenant; + } + + // Extract API version from header + final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; + apiVersionProperty[request] = apiVersion; + + return await innerHandler(request); + }; +} + +// Later in your route +class DataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final requestId = requestIdProperty[request]; + final tenant = tenantProperty[request]; + final apiVersion = apiVersionProperty[request]; + + session.log('Request $requestId for tenant $tenant (API v$apiVersion)'); + + // Fetch tenant-specific data + final data = await fetchTenantData(session, tenant); + + return Response.ok( + body: Body.fromString(jsonEncode(data), mimeType: MimeType.json), + ); + } +} +``` + +## Next Steps + +- Serve [static files](static-files) with caching and compression +- Use [typed headers](typed-headers) for type-safe header access +- Learn about [modular routes](modular-routes) for organizing complex APIs diff --git a/docs/06-concepts/18-webserver/05-static-files.md b/docs/06-concepts/18-webserver/05-static-files.md new file mode 100644 index 00000000..d24c5165 --- /dev/null +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -0,0 +1,187 @@ +# Static Files + +Static assets like images, CSS, and JavaScript files are essential for web +applications. The `StaticRoute.directory()` method makes it easy to serve entire +directories with automatic content-type detection for common file formats. + +## Serving static files + +The simplest way to serve static files is to use `StaticRoute.directory()`: + +```dart +final staticDir = Directory('web/static'); + +pod.webServer.addRoute( + StaticRoute.directory(staticDir), + '/static/**', +); +``` + +This serves all files from the `web/static` directory at the `/static` path. +For example, `web/static/logo.png` becomes accessible at `/static/logo.png`. + +:::info + +The `/**` tail-match wildcard is required for serving directories. It matches all +paths under the prefix, allowing `StaticRoute` to map URLs to file system paths. +See [Routing](routing#wildcards) for more on wildcards. + +::: + +## Cache control + +Control how browsers and CDNs cache your static files using the +`cacheControlFactory` parameter: + +```dart +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year + ), + '/static/**', +); +``` + +Available cache control factories: + +- **`StaticRoute.publicImmutable()`** - For versioned assets that never change + ```dart + StaticRoute.publicImmutable(maxAge: 31536000) // 1 year, perfect for cache-busted files + ``` +- **`StaticRoute.public()`** - For public assets with revalidation + ```dart + StaticRoute.public(maxAge: 3600) // 1 hour, then revalidate + ``` +- **`StaticRoute.privateNoCache()`** - For user-specific files + ```dart + StaticRoute.privateNoCache() // Must revalidate every time + ``` +- **`StaticRoute.noStore()`** - For sensitive content that shouldn't be cached + ```dart + StaticRoute.noStore() // Never cache + ``` + +## Static file cache-busting + +When deploying static assets, browsers and CDNs (like CloudFront) cache files +aggressively for performance. This means updated files may not be served to +users unless you implement a cache-busting strategy. + +Serverpod provides `CacheBustingConfig` to automatically version your static +files: + +```dart +final staticDir = Directory('web/static'); + +final cacheBustingConfig = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticDir, + separator: '@', // or use custom separator like '___' +); + +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), + ), + '/static/**', +); +``` + +### Generating versioned URLs + +Use the `assetPath()` method to generate cache-busted URLs for your assets: + +```dart +// In your route handler +final imageUrl = await cacheBustingConfig.assetPath('/static/logo.png'); +// Returns: /static/logo@.png + +// Pass to your template +return MyPageWidget(logoUrl: imageUrl); +``` + +The cache-busting system: + +- Automatically generates content-based hashes for asset versioning +- Allows custom separators (default `@`, but you can use `___` or any other) +- Preserves file extensions +- Works transparently - requesting `/static/logo@abc123.png` serves + `/static/logo.png` + +### Combining with cache control + +For optimal performance, combine cache-busting with aggressive caching: + +```dart +pod.webServer.addRoute( + StaticRoute.directory( + staticDir, + cacheBustingConfig: cacheBustingConfig, + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year + ), + '/static/**', +); +``` + +This approach ensures: + +- Browsers cache files for a long time (better performance) +- When files change, new hashes force cache invalidation +- No manual version management needed + +## Conditional requests (ETags and Last-Modified) + +`StaticRoute` automatically supports HTTP conditional requests through Relic's +`StaticHandler`. This provides efficient caching without transferring file +content when unchanged: + +**Supported features:** + +- **ETag headers** - Content-based fingerprinting for cache validation +- **Last-Modified headers** - Timestamp-based cache validation +- **If-None-Match** - Client sends ETag, server returns 304 Not Modified if + unchanged +- **If-Modified-Since** - Client sends timestamp, server returns 304 if not + modified + +These work automatically without configuration: + +**Initial request:** + +```http +GET /static/logo.png HTTP/1.1 +Host: example.com + +HTTP/1.1 200 OK +ETag: "abc123" +Last-Modified: Tue, 15 Nov 2024 12:00:00 GMT +Content-Length: 12345 + +[file content] +``` + +**Subsequent request with ETag:** + +```http +GET /static/logo.png HTTP/1.1 +Host: example.com +If-None-Match: "abc123" + +HTTP/1.1 304 Not Modified +ETag: "abc123" + +[no body - saves bandwidth] +``` + +When combined with cache-busting, conditional requests provide a fallback +validation mechanism even for cached assets, ensuring efficient delivery while +maintaining correctness. + +## Next Steps + +- Learn about [typed headers](typed-headers) for type-safe header access +- Explore [middleware](middleware) for cross-cutting concerns +- Understand [routing](routing) for custom request handling diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md new file mode 100644 index 00000000..3dc6fc6b --- /dev/null +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -0,0 +1,543 @@ +# Typed Headers + +HTTP headers are traditionally accessed as strings, which means you need to +manually parse values, handle edge cases, and validate formats. Serverpod's web +server (via Relic) provides a better approach: typed headers that automatically +parse header values into strongly-typed Dart objects. + +For example, instead of parsing `Authorization: Bearer abc123` as a string and +extracting the token yourself, you can access `request.headers.authorization` to +get a `BearerAuthorizationHeader` object with the token already parsed and +validated. This eliminates boilerplate code, catches errors early, and makes +your code more maintainable. + +## Reading typed headers + +Access typed headers through extension methods on `request.headers`: + +```dart +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Type-safe accessors return parsed objects or null + final auth = request.headers.authorization; // AuthorizationHeader? + final contentType = request.headers.contentType; // ContentTypeHeader? + final cookies = request.headers.cookie; // CookieHeader? + final userAgent = request.headers.userAgent; // String? + final host = request.headers.host; // HostHeader? + + // Raw string access is also available for any header + final authRaw = request.headers['Authorization']; // Iterable? + final custom = request.headers['X-Custom-Header']; // Iterable? + + return Response.ok(); + } +} +``` + +Common request headers include: + +- `authorization` - AuthorizationHeader (Bearer/Basic/Digest) +- `contentType` - ContentTypeHeader +- `contentLength` - int +- `cookie` - CookieHeader +- `accept` - AcceptHeader (media types) +- `acceptEncoding` - AcceptEncodingHeader +- `acceptLanguage` - AcceptLanguageHeader +- `userAgent` - String +- `host` - HostHeader +- `referer` - Uri +- `origin` - Uri + +## Setting typed headers + +Set typed headers in responses using the `Headers.build()` builder pattern: + +```dart +return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, + publicCache: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + + // Set custom headers (raw) + h['X-API-Version'] = ['2.0']; + }), + body: Body.fromString(jsonEncode(data)), +); +``` + +Common response headers include: + +- `cacheControl` - CacheControlHeader +- `setCookie` - SetCookieHeader +- `location` - Uri +- `contentDisposition` - ContentDispositionHeader +- `etag` - ETagHeader +- `vary` - VaryHeader + +## AuthorizationHeader - Authentication + +The `AuthorizationHeader` supports three authentication schemes: + +### Bearer Token (JWT, OAuth) + +```dart +final auth = request.headers.authorization; + +if (auth is BearerAuthorizationHeader) { + final token = auth.token; // The actual token string + + // Validate token + if (!await validateToken(token)) { + return Response.unauthorized(); + } +} +``` + +### Basic Authentication + +```dart +if (auth is BasicAuthorizationHeader) { + final username = auth.username; + final password = auth.password; + + // Validate credentials + if (!await validateCredentials(username, password)) { + return Response.unauthorized(); + } +} +``` + +### Setting Bearer token + +```dart +headers: Headers.build((h) { + h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); +}), +``` + +## CacheControlHeader - Cache directives + +Control caching behavior with type-safe cache directives: + +```dart +// Public cache with 1 hour expiration +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 3600, // Cache for 1 hour + publicCache: true, // Shared cache allowed + mustRevalidate: true, // Must revalidate after expiry + staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating + ); +}), +``` + +```dart +// Secure defaults for sensitive data +headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + noStore: true, // Don't store anywhere + noCache: true, // Must revalidate + privateCache: true, // Only private cache + ); +}), +``` + +Available directives: + +- `noCache`, `noStore` - Cache control flags +- `maxAge`, `sMaxAge` - Seconds of freshness +- `mustRevalidate`, `proxyRevalidate` - Revalidation requirements +- `publicCache`, `privateCache` - Cache scope +- `staleWhileRevalidate`, `staleIfError` - Stale caching +- `immutable` - Content never changes + +## ContentDispositionHeader - File downloads + +Specify how content should be handled (inline display or download): + +```dart +// File download with proper filename +headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter(name: 'filename', value: 'report.pdf'), + ], + ); +}), +``` + +```dart +// With extended encoding (RFC 5987) for non-ASCII filenames +h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: 'rapport.pdf', + isExtended: true, + encoding: 'UTF-8', + ), + ], +); +``` + +## CookieHeader and SetCookieHeader - Cookies + +### Reading cookies from requests + +```dart +final cookieHeader = request.headers.cookie; + +if (cookieHeader != null) { + // Find a specific cookie + final sessionId = cookieHeader.getCookie('session_id')?.value; + + // Iterate all cookies + for (final cookie in cookieHeader.cookies) { + print('${cookie.name}=${cookie.value}'); + } +} +``` + +### Setting cookies in responses + +```dart +headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: '12345abcde', + maxAge: 3600, // 1 hour + path: Uri.parse('/'), + domain: Uri.parse('example.com'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.strict, // CSRF protection + ); +}), +``` + +SameSite values: + +- `SameSite.lax` - Default, not sent on cross-site requests (except navigation) +- `SameSite.strict` - Never sent on cross-site requests +- `SameSite.none` - Sent on all requests (requires `secure: true`) + +## Complete examples + +### Secure API with authentication and caching + +```dart +class SecureApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + // Check authorization + final auth = request.headers.authorization; + if (auth is! BearerAuthorizationHeader) { + return Response.unauthorized(); + } + + // Validate token + if (!await validateToken(auth.token)) { + return Response.forbidden(); + } + + // Return data with cache headers + return Response.ok( + headers: Headers.build((h) { + h.cacheControl = CacheControlHeader( + maxAge: 300, // 5 minutes + publicCache: true, + mustRevalidate: true, + ); + h.contentType = ContentTypeHeader( + mimeType: MimeType.json, + charset: 'utf-8', + ); + }), + body: Body.fromString(jsonEncode(data)), + ); + } +} +``` + +### File download with proper headers + +```dart +class DownloadRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final fileId = request.pathParameters[#fileId]; + final file = await getFile(session, fileId); + + return Response.ok( + headers: Headers.build((h) { + h.contentDisposition = ContentDispositionHeader( + type: 'attachment', + parameters: [ + ContentDispositionParameter( + name: 'filename', + value: file.name, + isExtended: true, + encoding: 'UTF-8', + ), + ], + ); + h.contentType = ContentTypeHeader( + mimeType: file.mimeType, + ); + h.cacheControl = CacheControlHeader( + noCache: true, + mustRevalidate: true, + ); + }), + body: Body.fromBytes(file.content), + ); + } +} +``` + +### Cookie-based sessions + +```dart +class LoginRoute extends Route { + LoginRoute() : super(methods: {Method.post}); + + @override + Future handleCall(Session session, Request request) async { + // Authenticate user... + final sessionToken = await authenticateAndCreateSession(session, request); + + return Response.ok( + headers: Headers.build((h) { + h.setCookie = SetCookieHeader( + name: 'session_id', + value: sessionToken, + maxAge: 86400, // 24 hours + path: Uri.parse('/'), + secure: true, // HTTPS only + httpOnly: true, // No JavaScript access + sameSite: SameSite.lax, // CSRF protection + ); + }), + body: Body.fromString( + jsonEncode({'status': 'logged_in'}), + mimeType: MimeType.json, + ), + ); + } +} +``` + +:::tip Best Practices + +- Use typed headers for automatic parsing and validation +- Set appropriate cache headers for better performance +- Use `secure: true` and `httpOnly: true` for sensitive cookies +- Set proper `ContentDisposition` headers for file downloads +- Use `SameSite` cookie attribute for CSRF protection ::: + +## Creating custom typed headers + +While Relic provides typed headers for all standard HTTP headers, your +application may use custom headers for API versioning, feature flags, or +application-specific metadata. Rather than falling back to string-based access +for these custom headers, you can create your own typed headers using the same +pattern Relic uses internally. + +Creating a custom typed header involves three steps: defining the header class +with parsing logic, creating a codec for serialization, and setting up a +`HeaderAccessor` for type-safe access. Once configured, your custom headers work +just like the built-in ones, with automatic parsing, validation, and convenient +property-style access. + +Here's a complete example for a custom `X-API-Version` header: + +```dart +// Define your typed header class +final class ApiVersionHeader { + final int major; + final int minor; + + ApiVersionHeader({required this.major, required this.minor}); + + // Parse from string format "1.2" + factory ApiVersionHeader.parse(String value) { + final parts = value.split('.'); + if (parts.length != 2) { + throw const FormatException('Invalid API version format'); + } + + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); + + if (major == null || minor == null) { + throw const FormatException('Invalid API version numbers'); + } + + return ApiVersionHeader(major: major, minor: minor); + } + + // Encode to string format + String encode() => '$major.$minor'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApiVersionHeader && + major == other.major && + minor == other.minor; + + @override + int get hashCode => Object.hash(major, minor); + + @override + String toString() => 'ApiVersionHeader($major.$minor)'; +} + +// Create a HeaderCodec for encoding/decoding +const _apiVersionCodec = HeaderCodec.single( + ApiVersionHeader.parse, + (ApiVersionHeader value) => [value.encode()], +); + +// Create a global HeaderAccessor +const apiVersionHeader = HeaderAccessor( + 'x-api-version', + _apiVersionCodec, +); +``` + +### Using your custom header + +```dart +// Reading the header +class ApiRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final version = apiVersionHeader[request.headers](); + + if (version != null && version.major < 2) { + return Response.badRequest( + body: Body.fromString('API version 2.0 or higher required'), + ); + } + + return Response.ok(); + } +} + +// Setting the header +return Response.ok( + headers: Headers.build((h) { + apiVersionHeader[h].set(ApiVersionHeader(major: 2, minor: 1)); + }), +); +``` + +### Multi-value header example + +For headers that can have multiple comma-separated values: + +```dart +final class CustomTagsHeader { + final List tags; + + CustomTagsHeader({required List tags}) + : tags = List.unmodifiable(tags); + + // Parse from multiple values or comma-separated + factory CustomTagsHeader.parse(Iterable values) { + final allTags = values + .expand((v) => v.split(',')) + .map((t) => t.trim()) + .where((t) => t.isNotEmpty) + .toSet() + .toList(); + + if (allTags.isEmpty) { + throw const FormatException('Tags cannot be empty'); + } + + return CustomTagsHeader(tags: allTags); + } + + List encode() => [tags.join(', ')]; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CustomTagsHeader && + const ListEquality().equals(tags, other.tags); + + @override + int get hashCode => const ListEquality().hash(tags); +} + +// Use HeaderCodec (not HeaderCodec.single) for multi-value +const _customTagsCodec = HeaderCodec( + CustomTagsHeader.parse, + (CustomTagsHeader value) => value.encode(), +); + +const customTagsHeader = HeaderAccessor( + 'x-custom-tags', + _customTagsCodec, +); +``` + +### Optional: Add extension methods for convenient access + +For better ergonomics, you can add extension methods to access your custom +headers with property syntax: + +```dart +extension CustomHeadersEx on Headers { + ApiVersionHeader? get apiVersion => apiVersionHeader[this](); + CustomTagsHeader? get customTags => customTagsHeader[this](); +} + +extension CustomMutableHeadersEx on MutableHeaders { + set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); + set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); +} +``` + +Now you can use property syntax instead of the bracket notation: + +```dart +// Reading with property syntax +final version = request.headers.apiVersion; +final tags = request.headers.customTags; + +// Setting with property syntax +return Response.ok( + headers: Headers.build((h) { + h.apiVersion = ApiVersionHeader(major: 2, minor: 1); + h.customTags = CustomTagsHeader(tags: ['production', 'v2']); + }), +); +``` + +**Key points:** + +- Use `HeaderCodec.single()` when your header has only one value +- Use `HeaderCodec()` when your header can have multiple comma-separated values +- Define the `HeaderAccessor` as a global `const` +- Throw `FormatException` for invalid header values +- Implement `==` and `hashCode` for value equality +- The `HeaderAccessor` automatically caches parsed values for performance +- Optionally add extension methods for convenient property-style access + +## Next Steps + +- Serve [static files](static-files) with caching and compression +- Add [middleware](middleware) for cross-cutting concerns +- Learn about [modular routes](modular-routes) for organizing complex APIs diff --git a/docs/06-concepts/18-webserver/07-server-side-html.md b/docs/06-concepts/18-webserver/07-server-side-html.md new file mode 100644 index 00000000..88e380cd --- /dev/null +++ b/docs/06-concepts/18-webserver/07-server-side-html.md @@ -0,0 +1,128 @@ +# Server-Side HTML + +:::info Recommended Alternative + +For modern server-side HTML rendering with Serverpod, we recommend using +[Jaspr](https://docs.page/schultek/jaspr), which provides a Flutter-like API for +building web applications. You can integrate Jaspr with Serverpod's web server +using custom `Route` classes, giving you full control over request handling +while leveraging Jaspr's component model. + +The `WidgetRoute` and `TemplateWidget` classes described below are available for +simple use cases, but Jaspr provides better tooling and ongoing development for +production applications. + +::: + +## WidgetRoute and TemplateWidget + +When you create a new Serverpod project, it sets up a web server by default. +For simple HTML pages, you can use `WidgetRoute` and `TemplateWidget`. The +`WidgetRoute` provides an entry point for handling requests and returns a +`WebWidget`. The `TemplateWidget` renders web pages using Mustache templates. + +### Creating a WidgetRoute + +Create custom routes by extending the `WidgetRoute` class and implementing the +`build` method: + +```dart +class MyRoute extends WidgetRoute { + @override + Future build(Session session, Request request) async { + return MyPageWidget(title: 'Home page'); + } +} + +// Register the route +pod.webServer.addRoute(MyRoute(), '/my/page/address'); +``` + +### Creating a TemplateWidget + +A `TemplateWidget` consists of a Dart class and a corresponding HTML template +file. Create a custom widget by extending `TemplateWidget`: + +```dart +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = { + 'title': title, + }; + } +} +``` + +Place the corresponding HTML template in the `web/templates` directory. The HTML +file uses the [Mustache](https://mustache.github.io/) template language: + +```html + + + + {{title}} + + +

{{title}}

+

Welcome to my page!

+ + +``` + +Template values are converted to `String` objects before being passed to the +template. This makes it possible to nest widgets, similarly to how widgets work +in Flutter. + +## Built-in widgets + +Serverpod provides several built-in widgets for common use cases: + +- **`ListWidget`** - Concatenates multiple widgets into a single response + ```dart + return ListWidget(children: [ + HeaderWidget(), + ContentWidget(), + FooterWidget(), + ]); + ``` + +- **`JsonWidget`** - Renders JSON documents from serializable data structures + ```dart + return JsonWidget({'status': 'success', 'data': myData}); + ``` + +- **`RedirectWidget`** - Creates HTTP redirects to other URLs + ```dart + return RedirectWidget('/new/location'); + ``` + +## Database access and logging + +The web server passes a `Session` object to the `WidgetRoute` class' `build` +method. This gives you access to all the features you typically get from a +standard method call to an endpoint. Use the database, logging, or caching the +same way you would in a method call: + +```dart +class DataRoute extends WidgetRoute { + @override + Future build(Session session, Request request) async { + // Access the database + final users = await User.db.find(session); + + // Logging + session.log('Rendering user list page'); + + return UserListWidget(users: users); + } +} +``` + +## Next Steps + +- For modern server-side rendering, explore + [Jaspr](https://docs.page/schultek/jaspr) integration +- Use [custom routes](routing) for REST APIs and custom request handling +- Serve [static files](static-files) for CSS, JavaScript, and images +- Add [middleware](middleware) for cross-cutting concerns like logging and + error handling diff --git a/docs/06-concepts/18-webserver/_category_.json b/docs/06-concepts/18-webserver/_category_.json new file mode 100644 index 00000000..a41ff3e9 --- /dev/null +++ b/docs/06-concepts/18-webserver/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Web Server", + "collapsed": true +} From da476a08f526bcbfb4610342431be61f8a128c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 21 Nov 2025 13:45:51 +0100 Subject: [PATCH 25/39] docs: Use sequence diagram to explain middleware order --- .../06-concepts/18-webserver/04-middleware.md | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md index 729066ed..4040e5e8 100644 --- a/docs/06-concepts/18-webserver/04-middleware.md +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -193,13 +193,38 @@ pod.webServer.addMiddleware(rateLimitMiddleware, '/api'); // Executes third (inn For a request to `/api/users`, the execution order is: -1. `loggingMiddleware` (before) -2. `authMiddleware` (before) -3. `rateLimitMiddleware` (before) -4. Your route handler -5. `rateLimitMiddleware` (after) -6. `authMiddleware` (after) -7. `loggingMiddleware` (after) +```mermaid +sequenceDiagram + participant Client + participant Logging as loggingMiddleware + participant Auth as authMiddleware + participant RateLimit as rateLimitMiddleware + participant Handler as Route Handler + + Client->>Logging: Request /api/users + activate Logging + Note over Logging: Before logic + Logging->>Auth: + activate Auth + Note over Auth: Before logic + Auth->>RateLimit: + activate RateLimit + Note over RateLimit: Before logic + RateLimit->>Handler: + activate Handler + Note over Handler: Execute route logic + Handler-->>RateLimit: Response + deactivate Handler + Note over RateLimit: After logic + RateLimit-->>Auth: + deactivate RateLimit + Note over Auth: After logic + Auth-->>Logging: + deactivate Auth + Note over Logging: After logic + Logging-->>Client: Response + deactivate Logging +``` ## Request-scoped data with ContextProperty From c1a002e61e30758b749979e415b9af2d97d5017e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 21 Nov 2025 13:52:19 +0100 Subject: [PATCH 26/39] docs: Nit. We are cannot generate REST apis (old mistake) --- docs/06-concepts/18-webserver/01-overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md index 813db558..98759ada 100644 --- a/docs/06-concepts/18-webserver/01-overview.md +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -1,6 +1,6 @@ # Web Server Overview -In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or generate custom REST APIs to communicate with third-party services. +In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or define custom REST APIs to communicate with third-party services. Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. From be7dc48d9e55745a3ce7f00a4df1c6bffad2272c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Fri, 21 Nov 2025 14:01:06 +0100 Subject: [PATCH 27/39] docs: Fix lints --- docs/01-get-started/04-web-server.md | 2 ++ docs/08-upgrading/06-upgrade-to-three.md | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/01-get-started/04-web-server.md b/docs/01-get-started/04-web-server.md index efd07a6f..434117a2 100644 --- a/docs/01-get-started/04-web-server.md +++ b/docs/01-get-started/04-web-server.md @@ -103,6 +103,7 @@ await pod.start(); ``` This creates a complete CRUD API: + - `GET /api/recipes` - List all recipes - `GET /api/recipes/123` - Get a specific recipe by ID - `POST /api/recipes` - Create a new recipe @@ -177,6 +178,7 @@ pod.webServer.addRoute( ``` Now files in `web/static/` are accessible at `/static/`: + - `web/static/logo.png` → `http://localhost:8080/static/logo.png` - `web/static/css/style.css` → `http://localhost:8080/static/css/style.css` diff --git a/docs/08-upgrading/06-upgrade-to-three.md b/docs/08-upgrading/06-upgrade-to-three.md index 3180cba3..d3f0f6c8 100644 --- a/docs/08-upgrading/06-upgrade-to-three.md +++ b/docs/08-upgrading/06-upgrade-to-three.md @@ -71,6 +71,7 @@ pod.webServer.addRoute( ``` Other available cache control factory methods: + - `StaticRoute.public(maxAge: seconds)` - Public cache with optional max-age - `StaticRoute.publicImmutable(maxAge: seconds)` - Public immutable cache with optional max-age - `StaticRoute.privateNoCache()` - Private cache with no-cache directive From fee2c6ddc55de008e50506ec3958fb31afa3a3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 11:34:06 +0100 Subject: [PATCH 28/39] fix: Don't use title casing for titles or labels --- docs/06-concepts/18-webserver/01-overview.md | 24 +++++++++---------- docs/06-concepts/18-webserver/02-routing.md | 6 ++--- .../18-webserver/03-modular-routes.md | 8 +++---- .../06-concepts/18-webserver/04-middleware.md | 14 +++++------ .../18-webserver/05-static-files.md | 8 +++---- .../18-webserver/06-typed-headers.md | 20 ++++++++-------- .../18-webserver/07-server-side-html.md | 2 +- docs/06-concepts/18-webserver/_category_.json | 2 +- 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md index 98759ada..6d975237 100644 --- a/docs/06-concepts/18-webserver/01-overview.md +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -1,10 +1,10 @@ -# Web Server Overview +# Overview In addition to the application server, Serverpod comes with a built-in web server. The web server allows you to access your database and business layer the same way you would from a method call from an app. This makes it simple to share data for applications that need both an app and traditional web pages. You can also use the web server to create webhooks or define custom REST APIs to communicate with third-party services. Serverpod's web server is built on the [Relic](https://github.com/serverpod/relic) framework, giving you access to its routing engine, middleware system, and typed headers. This means you get the benefits of Serverpod's database integration and business logic alongside Relic's web server capabilities. -## Your First Route +## Your first route When you create a new Serverpod project, it sets up a web server by default. Here's how to add a simple API endpoint: @@ -33,9 +33,9 @@ await pod.start(); Visit `http://localhost:8080/api/hello` to see your API response. -## Core Concepts +## Core concepts -### Routes and Handlers +### Routes and handlers A **Route** is a destination in your web server that handles requests and generates responses. Routes extend the `Route` base class and implement the `handleCall()` method: @@ -53,7 +53,7 @@ The `handleCall()` method receives: - **Session** - Access to your database, logging, and authenticated user - **Request** - The HTTP request with headers, body, and URL information -### Response Types +### Response types Return different response types based on your needs: @@ -70,7 +70,7 @@ return Response.notFound(body: Body.fromString('Not found')); return Response.internalServerError(body: Body.fromString('Server error')); ``` -### Adding Routes +### Adding routes Routes are added with a path pattern: @@ -84,9 +84,9 @@ pod.webServer.addRoute(StaticRoute.directory(Directory('web')), '/static/**'); Routes are matched in the order they were added. -## When to Use What +## When to use what -### REST APIs → Custom Routes +### Rest apis → custom routes For REST APIs, webhooks, or custom HTTP handlers, use custom `Route` classes: ```dart @@ -106,7 +106,7 @@ class UsersApiRoute extends Route { See [Routing](routing) for details. -### Static Files → StaticRoute +### Static files → staticroute For serving CSS, JavaScript, images, or other static assets: ```dart @@ -118,10 +118,10 @@ pod.webServer.addRoute( See [Static Files](static-files) for cache-busting and optimization. -### HTML Pages → External Frameworks +### Html pages → external frameworks For server-side HTML rendering, consider integrating with [Jaspr](https://docs.page/schultek/jaspr) rather than using Serverpod's built-in HTML widgets. See [Server-Side HTML](server-side-html) for basic widget usage. -## Database Access +## Database access The `Session` parameter gives you full access to your Serverpod database: @@ -145,7 +145,7 @@ class UserRoute extends Route { } ``` -## Next Steps +## Next steps - **[Routing](routing)** - Learn about HTTP methods, path parameters, and wildcards - **[Modular Routes](modular-routes)** - Organize related endpoints with `injectIn()` diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index 20b20184..be509b58 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -6,7 +6,7 @@ provides powerful routing features for complex applications: HTTP method filtering, path parameters, wildcards, and fallback handling. Understanding these patterns helps you build clean, maintainable APIs. -## Custom Route classes +## Custom route classes While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base class gives you complete control over request handling. By extending `Route` and @@ -57,7 +57,7 @@ global error-handling middleware. ::: -## HTTP methods +## Http methods Routes can specify which HTTP methods they respond to using the `methods` parameter. The available methods are: @@ -210,7 +210,7 @@ class NotFoundRoute extends Route { pod.webServer.fallbackRoute = NotFoundRoute(); ``` -## Next Steps +## Next steps - Learn about [modular routes](modular-routes) for organizing complex APIs - Add [middleware](middleware) for request processing and error handling diff --git a/docs/06-concepts/18-webserver/03-modular-routes.md b/docs/06-concepts/18-webserver/03-modular-routes.md index 150d2f99..3d0cb9e2 100644 --- a/docs/06-concepts/18-webserver/03-modular-routes.md +++ b/docs/06-concepts/18-webserver/03-modular-routes.md @@ -1,4 +1,4 @@ -# Modular Routes +# Modular routes As your web server grows, managing dozens of individual route registrations can become unwieldy. Modular routes solve this by letting you group related @@ -13,7 +13,7 @@ multiple handler functions instead of implementing a single `handleCall()` method. This pattern is perfect for REST resources, API modules, or any group of related endpoints. -## Session Access in Modular Routes +## Session access in modular routes When using `injectIn()` with handler functions (`router.get('/', _handler)`), your handlers receive only a `Request` parameter. To access the `Session`, use @@ -30,7 +30,7 @@ This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. -## Creating a CRUD module +## Creating a crud module Here's an example of a modular CRUD route that registers multiple endpoints with path parameters: @@ -257,7 +257,7 @@ used. ::: -## Next Steps +## Next steps - Add [middleware](middleware) for cross-cutting concerns like logging and error handling diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md index 4040e5e8..8e60cfc9 100644 --- a/docs/06-concepts/18-webserver/04-middleware.md +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -45,7 +45,7 @@ Handler loggingMiddleware(Handler innerHandler) { } ``` -## API key validation middleware +## Api key validation middleware A common use case is validating API keys for protected routes: @@ -86,7 +86,7 @@ validation. ::: -## CORS middleware +## Cors middleware Enable Cross-Origin Resource Sharing for your API: @@ -226,7 +226,7 @@ sequenceDiagram deactivate Logging ``` -## Request-scoped data with ContextProperty +## Request-scoped data with contextproperty Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a @@ -249,7 +249,7 @@ request IDs, feature flags, or API version information extracted from headers. ::: -### Why use ContextProperty? +### Why use contextproperty? Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that @@ -264,7 +264,7 @@ include: - **Tenant identification** - Multi-tenant context from subdomains (when not part of user session) -### Creating a ContextProperty +### Creating a contextproperty Define a `ContextProperty` as a top-level constant or static field: @@ -344,7 +344,7 @@ class ApiRoute extends Route { } ``` -### Safe access with getOrNull +### Safe access with getornull If a value might not be set, use `getOrNull()` to avoid exceptions: @@ -490,7 +490,7 @@ class DataRoute extends Route { } ``` -## Next Steps +## Next steps - Serve [static files](static-files) with caching and compression - Use [typed headers](typed-headers) for type-safe header access diff --git a/docs/06-concepts/18-webserver/05-static-files.md b/docs/06-concepts/18-webserver/05-static-files.md index d24c5165..2f35fe7e 100644 --- a/docs/06-concepts/18-webserver/05-static-files.md +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -1,4 +1,4 @@ -# Static Files +# Static files Static assets like images, CSS, and JavaScript files are essential for web applications. The `StaticRoute.directory()` method makes it easy to serve entire @@ -90,7 +90,7 @@ pod.webServer.addRoute( ); ``` -### Generating versioned URLs +### Generating versioned urls Use the `assetPath()` method to generate cache-busted URLs for your assets: @@ -132,7 +132,7 @@ This approach ensures: - When files change, new hashes force cache invalidation - No manual version management needed -## Conditional requests (ETags and Last-Modified) +## Conditional requests (etags and last-modified) `StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file @@ -180,7 +180,7 @@ When combined with cache-busting, conditional requests provide a fallback validation mechanism even for cached assets, ensuring efficient delivery while maintaining correctness. -## Next Steps +## Next steps - Learn about [typed headers](typed-headers) for type-safe header access - Explore [middleware](middleware) for cross-cutting concerns diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md index 3dc6fc6b..28b252f0 100644 --- a/docs/06-concepts/18-webserver/06-typed-headers.md +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -1,4 +1,4 @@ -# Typed Headers +# Typed headers HTTP headers are traditionally accessed as strings, which means you need to manually parse values, handle edge cases, and validate formats. Serverpod's web @@ -81,11 +81,11 @@ Common response headers include: - `etag` - ETagHeader - `vary` - VaryHeader -## AuthorizationHeader - Authentication +## Authorizationheader - authentication The `AuthorizationHeader` supports three authentication schemes: -### Bearer Token (JWT, OAuth) +### Bearer token (jwt, oauth) ```dart final auth = request.headers.authorization; @@ -100,7 +100,7 @@ if (auth is BearerAuthorizationHeader) { } ``` -### Basic Authentication +### Basic authentication ```dart if (auth is BasicAuthorizationHeader) { @@ -114,7 +114,7 @@ if (auth is BasicAuthorizationHeader) { } ``` -### Setting Bearer token +### Setting bearer token ```dart headers: Headers.build((h) { @@ -122,7 +122,7 @@ headers: Headers.build((h) { }), ``` -## CacheControlHeader - Cache directives +## Cachecontrolheader - cache directives Control caching behavior with type-safe cache directives: @@ -158,7 +158,7 @@ Available directives: - `staleWhileRevalidate`, `staleIfError` - Stale caching - `immutable` - Content never changes -## ContentDispositionHeader - File downloads +## Contentdispositionheader - file downloads Specify how content should be handled (inline display or download): @@ -189,7 +189,7 @@ h.contentDisposition = ContentDispositionHeader( ); ``` -## CookieHeader and SetCookieHeader - Cookies +## Cookieheader and setcookieheader - cookies ### Reading cookies from requests @@ -232,7 +232,7 @@ SameSite values: ## Complete examples -### Secure API with authentication and caching +### Secure api with authentication and caching ```dart class SecureApiRoute extends Route { @@ -536,7 +536,7 @@ return Response.ok( - The `HeaderAccessor` automatically caches parsed values for performance - Optionally add extension methods for convenient property-style access -## Next Steps +## Next steps - Serve [static files](static-files) with caching and compression - Add [middleware](middleware) for cross-cutting concerns diff --git a/docs/06-concepts/18-webserver/07-server-side-html.md b/docs/06-concepts/18-webserver/07-server-side-html.md index 88e380cd..f2e928ca 100644 --- a/docs/06-concepts/18-webserver/07-server-side-html.md +++ b/docs/06-concepts/18-webserver/07-server-side-html.md @@ -118,7 +118,7 @@ class DataRoute extends WidgetRoute { } ``` -## Next Steps +## Next steps - For modern server-side rendering, explore [Jaspr](https://docs.page/schultek/jaspr) integration diff --git a/docs/06-concepts/18-webserver/_category_.json b/docs/06-concepts/18-webserver/_category_.json index a41ff3e9..7999273f 100644 --- a/docs/06-concepts/18-webserver/_category_.json +++ b/docs/06-concepts/18-webserver/_category_.json @@ -1,4 +1,4 @@ { - "label": "Web Server", + "label": "Web server", "collapsed": true } From 60caaf286cd02d58d932a753428ae97dcb943d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 11:49:24 +0100 Subject: [PATCH 29/39] fix: Remove Web server from getting started section again --- .../{05-deployment.md => 04-deployment.md} | 0 docs/01-get-started/04-web-server.md | 215 ------------------ 2 files changed, 215 deletions(-) rename docs/01-get-started/{05-deployment.md => 04-deployment.md} (100%) delete mode 100644 docs/01-get-started/04-web-server.md diff --git a/docs/01-get-started/05-deployment.md b/docs/01-get-started/04-deployment.md similarity index 100% rename from docs/01-get-started/05-deployment.md rename to docs/01-get-started/04-deployment.md diff --git a/docs/01-get-started/04-web-server.md b/docs/01-get-started/04-web-server.md deleted file mode 100644 index 434117a2..00000000 --- a/docs/01-get-started/04-web-server.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -sidebar_label: 4. Working with the web server ---- - -# Working with the web server - -In addition to endpoints for your Flutter app, Serverpod includes a built-in web server for REST APIs, static files, and webhooks. This is useful when you need to integrate with third-party services, serve web pages, or provide public APIs. The web server gives you full access to your database and business logic through the `Session` object, just like regular endpoints. - -:::info -The web server is built on the [Relic](https://github.com/serverpod/relic) framework, which provides routing, middleware, typed headers, and more. You get the benefits of Serverpod's database integration combined with Relic's web server capabilities. -::: - -## Building a REST API - -Let's create a complete REST API for managing recipes with support for listing, creating, retrieving, and deleting recipes. Create a new file `magic_recipe_server/lib/src/routes/recipe_route.dart`: - -```dart -import 'dart:convert'; -import 'package:serverpod/serverpod.dart'; - -class RecipeRoute extends Route { - // Specify which HTTP methods this route accepts - RecipeRoute() : super(methods: {Method.get, Method.post, Method.delete}); - - // Override injectIn to register multiple handler functions for different paths - // This is called "modular routing" and lets you organize related endpoints together - @override - void injectIn(RelicRouter router) { - router - ..get('/', _list) // GET /api/recipes - ..get('/:id', _get) // GET /api/recipes/123 - ..post('/', _create) // POST /api/recipes - ..delete('/:id', _delete); // DELETE /api/recipes/123 - } - - Future _list(Request request) async { - // Access the Session through request.session (modular routes only get Request) - final recipes = await Recipe.db.find(request.session, limit: 10); - - return Response.ok( - body: Body.fromString( - jsonEncode({'recipes': recipes.map((r) => r.toJson()).toList()}), - mimeType: MimeType.json, // Set proper content type - ), - ); - } - - Future _get(Request request) async { - // Path parameters are accessed using symbols: pathParameters[#id] - final id = int.tryParse(request.pathParameters[#id] ?? ''); - if (id == null) return Response.badRequest(); - - final recipe = await Recipe.db.findById(request.session, id); - - // Return different status codes based on the result - if (recipe == null) return Response.notFound(); - - return Response.ok( - body: Body.fromString(jsonEncode(recipe.toJson()), mimeType: MimeType.json), - ); - } - - Future _create(Request request) async { - // Read and parse the request body - final data = jsonDecode(await request.readAsString()); - final recipe = Recipe( - title: data['title'], - ingredients: data['ingredients'], - ); - await Recipe.db.insertRow(request.session, recipe); - - // Return 201 Created with the new resource - return Response.created( - body: Body.fromString(jsonEncode(recipe.toJson()), mimeType: MimeType.json), - ); - } - - Future _delete(Request request) async { - final id = int.tryParse(request.pathParameters[#id] ?? ''); - if (id == null) return Response.badRequest(); - - await Recipe.db.deleteRow(request.session, id); - - // 204 No Content is appropriate for successful DELETE - return Response.noContent(); - } - - // When using injectIn, handleCall is not used - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('Uses injectIn'); - } -} -``` - -Register the route in your `server.dart` file before calling `pod.start()`: - -```dart -// Add your web routes here -pod.webServer.addRoute(RecipeRoute(), '/api/recipes'); - -await pod.start(); -``` - -This creates a complete CRUD API: - -- `GET /api/recipes` - List all recipes -- `GET /api/recipes/123` - Get a specific recipe by ID -- `POST /api/recipes` - Create a new recipe -- `DELETE /api/recipes/123` - Delete a recipe - -You can test it with curl: - -```bash -# List recipes -curl http://localhost:8080/api/recipes - -# Get a specific recipe -curl http://localhost:8080/api/recipes/1 - -# Create a new recipe -curl -X POST http://localhost:8080/api/recipes \ - -H "Content-Type: application/json" \ - -d '{"title":"Pasta","ingredients":"Tomatoes, pasta, basil"}' - -# Delete a recipe -curl -X DELETE http://localhost:8080/api/recipes/1 -``` - -## Middleware for cross-cutting concerns - -Middleware lets you add functionality that applies to multiple routes, like logging, authentication, or error handling. Middleware functions wrap your route handlers and can inspect or modify requests and responses. - -```dart -// Add this before your route registrations -Handler loggingMiddleware(Handler next) { - return (Request request) async { - final start = DateTime.now(); - print('→ ${request.method.name} ${request.url.path}'); - - // Call the next handler in the chain - final response = await next(request); - - final duration = DateTime.now().difference(start); - print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); - - return response; - }; -} - -// Apply to all routes -pod.webServer.addMiddleware(loggingMiddleware, '/'); -``` - -You can add multiple middleware functions and scope them to specific paths: - -```dart -// Only apply to API routes -pod.webServer.addMiddleware(authenticationMiddleware, '/api'); -``` - -## Serving static files - -For serving CSS, JavaScript, images, and other static assets, use `StaticRoute`: - -```dart -import 'dart:io'; - -// Serve files from the web/static directory -pod.webServer.addRoute( - StaticRoute.directory( - Directory('web/static'), - // Optional: set cache control for better performance - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), - ), - '/static/**', // The /** wildcard matches all paths under /static/ -); -``` - -Now files in `web/static/` are accessible at `/static/`: - -- `web/static/logo.png` → `http://localhost:8080/static/logo.png` -- `web/static/css/style.css` → `http://localhost:8080/static/css/style.css` - -## Advanced features - -The web server includes many more features for production-ready APIs: - -**Typed headers** - Access headers in a type-safe way instead of raw strings: - -```dart -// Instead of: request.headers['Authorization'] -final auth = request.headers.authorization; // Returns AuthorizationHeader? -if (auth is BearerAuthorizationHeader) { - final token = auth.token; // Automatically parsed -} -``` - -**ContextProperty** - Attach request-scoped data that middleware can set and routes can read: - -```dart -final requestIdProperty = ContextProperty(); - -// In middleware: attach a request ID -requestIdProperty[request] = Uuid().v4(); - -// In route: access the request ID -final requestId = requestIdProperty[request]; -``` - -**Webhooks** - Handle incoming webhooks from third-party services by validating signatures and processing events. - -**Cache-busting** - Automatically version static assets with content hashes for optimal caching. - -See the full [Web Server documentation](../concepts/webserver/overview) for details on [routing](../concepts/webserver/routing), [middleware](../concepts/webserver/middleware), [typed headers](../concepts/webserver/typed-headers), [static files](../concepts/webserver/static-files), and more. From 84d8877a51bdc8a3d08b6de451cde36d5229474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:04:39 +0100 Subject: [PATCH 30/39] fix: Move modular routes as a subsection of routing --- docs/06-concepts/18-webserver/01-overview.md | 5 +- docs/06-concepts/18-webserver/02-routing.md | 281 +++++++++++++++++- .../18-webserver/03-modular-routes.md | 265 ----------------- 3 files changed, 273 insertions(+), 278 deletions(-) delete mode 100644 docs/06-concepts/18-webserver/03-modular-routes.md diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md index 6d975237..6ddf5ac4 100644 --- a/docs/06-concepts/18-webserver/01-overview.md +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -106,7 +106,7 @@ class UsersApiRoute extends Route { See [Routing](routing) for details. -### Static files → staticroute +### Static files → `StaticRoute` For serving CSS, JavaScript, images, or other static assets: ```dart @@ -118,9 +118,6 @@ pod.webServer.addRoute( See [Static Files](static-files) for cache-busting and optimization. -### Html pages → external frameworks -For server-side HTML rendering, consider integrating with [Jaspr](https://docs.page/schultek/jaspr) rather than using Serverpod's built-in HTML widgets. See [Server-Side HTML](server-side-html) for basic widget usage. - ## Database access The `Session` parameter gives you full access to your Serverpod database: diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index be509b58..5403635a 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -6,13 +6,12 @@ provides powerful routing features for complex applications: HTTP method filtering, path parameters, wildcards, and fallback handling. Understanding these patterns helps you build clean, maintainable APIs. -## Custom route classes +## Route classes -While `WidgetRoute` is convenient for rendering HTML pages, the `Route` base -class gives you complete control over request handling. By extending `Route` and -implementing `handleCall()`, you can build REST APIs, serve files, or handle any -custom HTTP interaction. This is ideal when you need to work directly with -request bodies, headers, and response formats. +The `Route` base class gives you complete control over request handling. By +extending `Route` and implementing `handleCall()`, you can build REST APIs, +serve files, or handle any custom HTTP interaction. This is ideal when you need +to work directly with request bodies, headers, and response formats. ```dart class ApiRoute extends Route { @@ -44,7 +43,11 @@ class ApiRoute extends Route { ); } } +``` + +You need to register your custom routes with the build in router in under a given path: +```dart // Register the route pod.webServer.addRoute(ApiRoute(), '/api/data'); ``` @@ -210,8 +213,268 @@ class NotFoundRoute extends Route { pod.webServer.fallbackRoute = NotFoundRoute(); ``` +## Modular routes + +As your web server grows, managing dozens of individual route registrations can +become unwieldy. Modular routes solve this by letting you group related +endpoints into reusable modules. For example, you might create a +`UserCrudModule` that handles all user-related endpoints (`GET /users`, +`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. + +The key to modular routes is the `injectIn()` method. When you call +`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` +on a router group for that path. By overriding `injectIn()`, you can register +multiple handler functions instead of implementing a single `handleCall()` +method. This pattern is perfect for REST resources, API modules, or any group of +related endpoints. + +### Session access in modular routes + +When using `injectIn()` with handler functions (`router.get('/', _handler)`), +your handlers receive only a `Request` parameter. To access the `Session`, use +`request.session`: + +```dart +Future _handler(Request request) async { + final session = request.session; // Extract Session from Request + // ... use session +} +``` + +This differs from `Route.handleCall()` which receives both Session and Request +as explicit parameters. The modular route pattern uses Relic's router directly, +which only provides Request to handlers. + +### Creating a crud module + +Here's an example of a modular CRUD route that registers multiple endpoints with +path parameters: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get) + ..post('/', _create) + ..put('/:id', _update) + ..delete('/:id', _delete); + } + + // Handler methods + Future _list(Request request) async { + final session = request.session; + final users = await User.db.find(session); + + return Response.ok( + body: Body.fromString( + jsonEncode(users.map((u) => u.toJson()).toList()), + mimeType: MimeType.json, + ), + ); + } + + Future _get(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final user = await User.db.findById(session, userId); + + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _create(Request request) async { + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = User(name: data['name'], email: data['email']); + await User.db.insertRow(session, user); + + return Response.created( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _update(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final body = await request.readAsString(); + final data = jsonDecode(body); + final session = request.session; + + final user = await User.db.findById(session, userId); + if (user == null) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + user.name = data['name'] ?? user.name; + user.email = data['email'] ?? user.email; + await User.db.updateRow(session, user); + + return Response.ok( + body: Body.fromString( + jsonEncode(user.toJson()), + mimeType: MimeType.json, + ), + ); + } + + Future _delete(Request request) async { + // Extract path parameter using symbol + final id = request.pathParameters[#id]; + if (id == null) { + return Response.badRequest( + body: Body.fromString('Missing user ID'), + ); + } + + final userId = int.tryParse(id); + if (userId == null) { + return Response.badRequest( + body: Body.fromString('Invalid user ID'), + ); + } + + final session = request.session; + final deleted = await User.db.deleteRow(session, userId); + + if (!deleted) { + return Response.notFound( + body: Body.fromString('User not found'), + ); + } + + return Response.noContent(); + } + + // Required by Route but not used since we override injectIn + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire CRUD module under /api/users +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` + +This creates the following RESTful endpoints: + +- `GET /api/users` - List all users +- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) +- `POST /api/users` - Create a new user +- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) +- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) + +:::tip + +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. +Always validate and parse these values since they come from user input as +strings. + +::: + +### Composing multiple modules + +You can create a parent module that composes multiple sub-modules: + +```dart +class ApiModule extends Route { + @override + void injectIn(RelicRouter router) { + // Inject sub-modules at different paths + router.group('/users').inject(UserCrudModule()); + router.group('/posts').inject(PostCrudModule()); + router.group('/comments').inject(CommentCrudModule()); + + // Add module-level routes + router.get('/health', _healthCheck); + } + + Future _healthCheck(Request request) async { + return Response.ok( + body: Body.fromString( + jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), + mimeType: MimeType.json, + ), + ); + } + + @override + Future handleCall(Session session, Request request) async { + throw UnimplementedError('This route uses injectIn'); + } +} + +// Register the entire API module +pod.webServer.addRoute(ApiModule(), '/api'); +``` + +This pattern enables you to: + +- **Organize routes hierarchically** - Group related functionality together +- **Reuse route modules** - Use the same module in different applications +- **Compose complex APIs** - Build large APIs from smaller, focused modules +- **Separate concerns** - Keep route registration logic separate from handler + implementation + +:::tip + +When overriding `injectIn`, you typically don't need to implement `handleCall` +since you're registering handler functions directly with the router. You can +throw `UnimplementedError` in `handleCall` to make it clear the method isn't +used. + +::: + ## Next steps -- Learn about [modular routes](modular-routes) for organizing complex APIs -- Add [middleware](middleware) for request processing and error handling -- Serve [static files](static-files) like images and assets +- Add [middleware](middleware) for cross-cutting concerns like logging and + error handling +- Learn about [typed headers](typed-headers) for type-safe header access +- Explore [static file serving](static-files) for assets and downloads diff --git a/docs/06-concepts/18-webserver/03-modular-routes.md b/docs/06-concepts/18-webserver/03-modular-routes.md deleted file mode 100644 index 3d0cb9e2..00000000 --- a/docs/06-concepts/18-webserver/03-modular-routes.md +++ /dev/null @@ -1,265 +0,0 @@ -# Modular routes - -As your web server grows, managing dozens of individual route registrations can -become unwieldy. Modular routes solve this by letting you group related -endpoints into reusable modules. For example, you might create a -`UserCrudModule` that handles all user-related endpoints (`GET /users`, -`POST /users`, `PUT /users/:id`, etc.) in a single cohesive unit. - -The key to modular routes is the `injectIn()` method. When you call -`pod.webServer.addRoute(route, path)`, Serverpod calls `route.injectIn(router)` -on a router group for that path. By overriding `injectIn()`, you can register -multiple handler functions instead of implementing a single `handleCall()` -method. This pattern is perfect for REST resources, API modules, or any group of -related endpoints. - -## Session access in modular routes - -When using `injectIn()` with handler functions (`router.get('/', _handler)`), -your handlers receive only a `Request` parameter. To access the `Session`, use -`request.session`: - -```dart -Future _handler(Request request) async { - final session = request.session; // Extract Session from Request - // ... use session -} -``` - -This differs from `Route.handleCall()` which receives both Session and Request -as explicit parameters. The modular route pattern uses Relic's router directly, -which only provides Request to handlers. - -## Creating a crud module - -Here's an example of a modular CRUD route that registers multiple endpoints with -path parameters: - -```dart -class UserCrudModule extends Route { - @override - void injectIn(RelicRouter router) { - // Register multiple routes with path parameters - router - ..get('/', _list) - ..get('/:id', _get) - ..post('/', _create) - ..put('/:id', _update) - ..delete('/:id', _delete); - } - - // Handler methods - Future _list(Request request) async { - final session = request.session; - final users = await User.db.find(session); - - return Response.ok( - body: Body.fromString( - jsonEncode(users.map((u) => u.toJson()).toList()), - mimeType: MimeType.json, - ), - ); - } - - Future _get(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final session = request.session; - final user = await User.db.findById(session, userId); - - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _create(Request request) async { - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = User(name: data['name'], email: data['email']); - await User.db.insertRow(session, user); - - return Response.created( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _update(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = await User.db.findById(session, userId); - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - user.name = data['name'] ?? user.name; - user.email = data['email'] ?? user.email; - await User.db.updateRow(session, user); - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _delete(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final session = request.session; - final deleted = await User.db.deleteRow(session, userId); - - if (!deleted) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - return Response.noContent(); - } - - // Required by Route but not used since we override injectIn - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire CRUD module under /api/users -pod.webServer.addRoute(UserCrudModule(), '/api/users'); -``` - -This creates the following RESTful endpoints: - -- `GET /api/users` - List all users -- `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) -- `POST /api/users` - Create a new user -- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) -- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) - -:::tip - -Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. -Always validate and parse these values since they come from user input as -strings. - -::: - -## Composing multiple modules - -You can create a parent module that composes multiple sub-modules: - -```dart -class ApiModule extends Route { - @override - void injectIn(RelicRouter router) { - // Inject sub-modules at different paths - router.group('/users').inject(UserCrudModule()); - router.group('/posts').inject(PostCrudModule()); - router.group('/comments').inject(CommentCrudModule()); - - // Add module-level routes - router.get('/health', _healthCheck); - } - - Future _healthCheck(Request request) async { - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), - mimeType: MimeType.json, - ), - ); - } - - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire API module -pod.webServer.addRoute(ApiModule(), '/api'); -``` - -This pattern enables you to: - -- **Organize routes hierarchically** - Group related functionality together -- **Reuse route modules** - Use the same module in different applications -- **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler - implementation - -:::tip - -When overriding `injectIn`, you typically don't need to implement `handleCall` -since you're registering handler functions directly with the router. You can -throw `UnimplementedError` in `handleCall` to make it clear the method isn't -used. - -::: - -## Next steps - -- Add [middleware](middleware) for cross-cutting concerns like logging and - error handling -- Learn about [typed headers](typed-headers) for type-safe header access -- Explore [static file serving](static-files) for assets and downloads From 211dadd426ae7313391ee76456425f750d1b9a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:17:13 +0100 Subject: [PATCH 31/39] fix: Slim down typed headers section --- .../18-webserver/06-typed-headers.md | 314 +----------------- 1 file changed, 3 insertions(+), 311 deletions(-) diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md index 28b252f0..09f79000 100644 --- a/docs/06-concepts/18-webserver/06-typed-headers.md +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -81,268 +81,15 @@ Common response headers include: - `etag` - ETagHeader - `vary` - VaryHeader -## Authorizationheader - authentication - -The `AuthorizationHeader` supports three authentication schemes: - -### Bearer token (jwt, oauth) - -```dart -final auth = request.headers.authorization; - -if (auth is BearerAuthorizationHeader) { - final token = auth.token; // The actual token string - - // Validate token - if (!await validateToken(token)) { - return Response.unauthorized(); - } -} -``` - -### Basic authentication - -```dart -if (auth is BasicAuthorizationHeader) { - final username = auth.username; - final password = auth.password; - - // Validate credentials - if (!await validateCredentials(username, password)) { - return Response.unauthorized(); - } -} -``` - -### Setting bearer token - -```dart -headers: Headers.build((h) { - h.authorization = BearerAuthorizationHeader(token: 'eyJhbGc...'); -}), -``` - -## Cachecontrolheader - cache directives - -Control caching behavior with type-safe cache directives: - -```dart -// Public cache with 1 hour expiration -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 3600, // Cache for 1 hour - publicCache: true, // Shared cache allowed - mustRevalidate: true, // Must revalidate after expiry - staleWhileRevalidate: 86400, // Can use stale for 1 day while revalidating - ); -}), -``` - -```dart -// Secure defaults for sensitive data -headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - noStore: true, // Don't store anywhere - noCache: true, // Must revalidate - privateCache: true, // Only private cache - ); -}), -``` - -Available directives: - -- `noCache`, `noStore` - Cache control flags -- `maxAge`, `sMaxAge` - Seconds of freshness -- `mustRevalidate`, `proxyRevalidate` - Revalidation requirements -- `publicCache`, `privateCache` - Cache scope -- `staleWhileRevalidate`, `staleIfError` - Stale caching -- `immutable` - Content never changes - -## Contentdispositionheader - file downloads - -Specify how content should be handled (inline display or download): - -```dart -// File download with proper filename -headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter(name: 'filename', value: 'report.pdf'), - ], - ); -}), -``` - -```dart -// With extended encoding (RFC 5987) for non-ASCII filenames -h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: 'rapport.pdf', - isExtended: true, - encoding: 'UTF-8', - ), - ], -); -``` - -## Cookieheader and setcookieheader - cookies - -### Reading cookies from requests - -```dart -final cookieHeader = request.headers.cookie; - -if (cookieHeader != null) { - // Find a specific cookie - final sessionId = cookieHeader.getCookie('session_id')?.value; - - // Iterate all cookies - for (final cookie in cookieHeader.cookies) { - print('${cookie.name}=${cookie.value}'); - } -} -``` - -### Setting cookies in responses - -```dart -headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: '12345abcde', - maxAge: 3600, // 1 hour - path: Uri.parse('/'), - domain: Uri.parse('example.com'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.strict, // CSRF protection - ); -}), -``` - -SameSite values: - -- `SameSite.lax` - Default, not sent on cross-site requests (except navigation) -- `SameSite.strict` - Never sent on cross-site requests -- `SameSite.none` - Sent on all requests (requires `secure: true`) - -## Complete examples - -### Secure api with authentication and caching - -```dart -class SecureApiRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - // Check authorization - final auth = request.headers.authorization; - if (auth is! BearerAuthorizationHeader) { - return Response.unauthorized(); - } - - // Validate token - if (!await validateToken(auth.token)) { - return Response.forbidden(); - } - - // Return data with cache headers - return Response.ok( - headers: Headers.build((h) { - h.cacheControl = CacheControlHeader( - maxAge: 300, // 5 minutes - publicCache: true, - mustRevalidate: true, - ); - h.contentType = ContentTypeHeader( - mimeType: MimeType.json, - charset: 'utf-8', - ); - }), - body: Body.fromString(jsonEncode(data)), - ); - } -} -``` - -### File download with proper headers - -```dart -class DownloadRoute extends Route { - @override - Future handleCall(Session session, Request request) async { - final fileId = request.pathParameters[#fileId]; - final file = await getFile(session, fileId); - - return Response.ok( - headers: Headers.build((h) { - h.contentDisposition = ContentDispositionHeader( - type: 'attachment', - parameters: [ - ContentDispositionParameter( - name: 'filename', - value: file.name, - isExtended: true, - encoding: 'UTF-8', - ), - ], - ); - h.contentType = ContentTypeHeader( - mimeType: file.mimeType, - ); - h.cacheControl = CacheControlHeader( - noCache: true, - mustRevalidate: true, - ); - }), - body: Body.fromBytes(file.content), - ); - } -} -``` - -### Cookie-based sessions - -```dart -class LoginRoute extends Route { - LoginRoute() : super(methods: {Method.post}); - - @override - Future handleCall(Session session, Request request) async { - // Authenticate user... - final sessionToken = await authenticateAndCreateSession(session, request); - - return Response.ok( - headers: Headers.build((h) { - h.setCookie = SetCookieHeader( - name: 'session_id', - value: sessionToken, - maxAge: 86400, // 24 hours - path: Uri.parse('/'), - secure: true, // HTTPS only - httpOnly: true, // No JavaScript access - sameSite: SameSite.lax, // CSRF protection - ); - }), - body: Body.fromString( - jsonEncode({'status': 'logged_in'}), - mimeType: MimeType.json, - ), - ); - } -} -``` - :::tip Best Practices - Use typed headers for automatic parsing and validation - Set appropriate cache headers for better performance - Use `secure: true` and `httpOnly: true` for sensitive cookies - Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection ::: +- Use `SameSite` cookie attribute for CSRF protection + +::: ## Creating custom typed headers @@ -442,57 +189,6 @@ return Response.ok( ); ``` -### Multi-value header example - -For headers that can have multiple comma-separated values: - -```dart -final class CustomTagsHeader { - final List tags; - - CustomTagsHeader({required List tags}) - : tags = List.unmodifiable(tags); - - // Parse from multiple values or comma-separated - factory CustomTagsHeader.parse(Iterable values) { - final allTags = values - .expand((v) => v.split(',')) - .map((t) => t.trim()) - .where((t) => t.isNotEmpty) - .toSet() - .toList(); - - if (allTags.isEmpty) { - throw const FormatException('Tags cannot be empty'); - } - - return CustomTagsHeader(tags: allTags); - } - - List encode() => [tags.join(', ')]; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is CustomTagsHeader && - const ListEquality().equals(tags, other.tags); - - @override - int get hashCode => const ListEquality().hash(tags); -} - -// Use HeaderCodec (not HeaderCodec.single) for multi-value -const _customTagsCodec = HeaderCodec( - CustomTagsHeader.parse, - (CustomTagsHeader value) => value.encode(), -); - -const customTagsHeader = HeaderAccessor( - 'x-custom-tags', - _customTagsCodec, -); -``` - ### Optional: Add extension methods for convenient access For better ergonomics, you can add extension methods to access your custom @@ -501,12 +197,10 @@ headers with property syntax: ```dart extension CustomHeadersEx on Headers { ApiVersionHeader? get apiVersion => apiVersionHeader[this](); - CustomTagsHeader? get customTags => customTagsHeader[this](); } extension CustomMutableHeadersEx on MutableHeaders { set apiVersion(ApiVersionHeader? value) => apiVersionHeader[this].set(value); - set customTags(CustomTagsHeader? value) => customTagsHeader[this].set(value); } ``` @@ -515,13 +209,11 @@ Now you can use property syntax instead of the bracket notation: ```dart // Reading with property syntax final version = request.headers.apiVersion; -final tags = request.headers.customTags; // Setting with property syntax return Response.ok( headers: Headers.build((h) { h.apiVersion = ApiVersionHeader(major: 2, minor: 1); - h.customTags = CustomTagsHeader(tags: ['production', 'v2']); }), ); ``` From d1cd79452ae91127545e6c1f13a46620ccfb0917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:20:52 +0100 Subject: [PATCH 32/39] fix: Drop modular route example (belongs in Relic) --- docs/06-concepts/18-webserver/02-routing.md | 53 --------------------- 1 file changed, 53 deletions(-) diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index 5403635a..59e73392 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -419,59 +419,6 @@ strings. ::: -### Composing multiple modules - -You can create a parent module that composes multiple sub-modules: - -```dart -class ApiModule extends Route { - @override - void injectIn(RelicRouter router) { - // Inject sub-modules at different paths - router.group('/users').inject(UserCrudModule()); - router.group('/posts').inject(PostCrudModule()); - router.group('/comments').inject(CommentCrudModule()); - - // Add module-level routes - router.get('/health', _healthCheck); - } - - Future _healthCheck(Request request) async { - return Response.ok( - body: Body.fromString( - jsonEncode({'status': 'healthy', 'timestamp': DateTime.now().toIso8601String()}), - mimeType: MimeType.json, - ), - ); - } - - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } -} - -// Register the entire API module -pod.webServer.addRoute(ApiModule(), '/api'); -``` - -This pattern enables you to: - -- **Organize routes hierarchically** - Group related functionality together -- **Reuse route modules** - Use the same module in different applications -- **Compose complex APIs** - Build large APIs from smaller, focused modules -- **Separate concerns** - Keep route registration logic separate from handler - implementation - -:::tip - -When overriding `injectIn`, you typically don't need to implement `handleCall` -since you're registering handler functions directly with the router. You can -throw `UnimplementedError` in `handleCall` to make it clear the method isn't -used. - -::: - ## Next steps - Add [middleware](middleware) for cross-cutting concerns like logging and From eee902d132de4898478087ba6d62d962343f49a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:31:00 +0100 Subject: [PATCH 33/39] fix: Reduce size of module example --- docs/06-concepts/18-webserver/02-routing.md | 99 +-------------------- 1 file changed, 2 insertions(+), 97 deletions(-) diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index 59e73392..6c489ec3 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -245,9 +245,9 @@ This differs from `Route.handleCall()` which receives both Session and Request as explicit parameters. The modular route pattern uses Relic's router directly, which only provides Request to handlers. -### Creating a crud module +### Creating a module -Here's an example of a modular CRUD route that registers multiple endpoints with +Here's an example of a modular route that registers multiple endpoints with path parameters: ```dart @@ -258,9 +258,6 @@ class UserCrudModule extends Route { router ..get('/', _list) ..get('/:id', _get) - ..post('/', _create) - ..put('/:id', _update) - ..delete('/:id', _delete); } // Handler methods @@ -308,95 +305,6 @@ class UserCrudModule extends Route { ), ); } - - Future _create(Request request) async { - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = User(name: data['name'], email: data['email']); - await User.db.insertRow(session, user); - - return Response.created( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _update(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final body = await request.readAsString(); - final data = jsonDecode(body); - final session = request.session; - - final user = await User.db.findById(session, userId); - if (user == null) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - user.name = data['name'] ?? user.name; - user.email = data['email'] ?? user.email; - await User.db.updateRow(session, user); - - return Response.ok( - body: Body.fromString( - jsonEncode(user.toJson()), - mimeType: MimeType.json, - ), - ); - } - - Future _delete(Request request) async { - // Extract path parameter using symbol - final id = request.pathParameters[#id]; - if (id == null) { - return Response.badRequest( - body: Body.fromString('Missing user ID'), - ); - } - - final userId = int.tryParse(id); - if (userId == null) { - return Response.badRequest( - body: Body.fromString('Invalid user ID'), - ); - } - - final session = request.session; - final deleted = await User.db.deleteRow(session, userId); - - if (!deleted) { - return Response.notFound( - body: Body.fromString('User not found'), - ); - } - - return Response.noContent(); - } - - // Required by Route but not used since we override injectIn - @override - Future handleCall(Session session, Request request) async { - throw UnimplementedError('This route uses injectIn'); - } } // Register the entire CRUD module under /api/users @@ -407,9 +315,6 @@ This creates the following RESTful endpoints: - `GET /api/users` - List all users - `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) -- `POST /api/users` - Create a new user -- `PUT /api/users/:id` - Update a user (e.g., `/api/users/123`) -- `DELETE /api/users/:id` - Delete a user (e.g., `/api/users/123`) :::tip From 60a663a9b688e238dff7f981b058a490e73dd7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:34:55 +0100 Subject: [PATCH 34/39] fix: Some re-ordering --- docs/06-concepts/18-webserver/02-routing.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index 6c489ec3..2148cfcf 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -156,6 +156,14 @@ router.get('/:userId/posts/:postId', handler); // request.pathParameters[#postId] => '456' ``` +:::tip + +Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. +Always validate and parse these values since they come from user input as +strings. + +::: + ## Wildcards Routes also support wildcard matching for catching all paths: @@ -316,14 +324,6 @@ This creates the following RESTful endpoints: - `GET /api/users` - List all users - `GET /api/users/:id` - Get a specific user (e.g., `/api/users/123`) -:::tip - -Path parameters are accessed using symbols: `request.pathParameters[#paramName]`. -Always validate and parse these values since they come from user input as -strings. - -::: - ## Next steps - Add [middleware](middleware) for cross-cutting concerns like logging and From 9e6cc3820a3c6aa24677a463dbb73dfb2f4a79ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 12:52:31 +0100 Subject: [PATCH 35/39] fix: maxAge is now a Duration. Remove overlooked duplicated section --- .../18-webserver/05-static-files.md | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/docs/06-concepts/18-webserver/05-static-files.md b/docs/06-concepts/18-webserver/05-static-files.md index 2f35fe7e..ae546f8f 100644 --- a/docs/06-concepts/18-webserver/05-static-files.md +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -37,7 +37,7 @@ Control how browsers and CDNs cache your static files using the pod.webServer.addRoute( StaticRoute.directory( staticDir, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year + cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(years: 1)), ), '/static/**', ); @@ -47,11 +47,11 @@ Available cache control factories: - **`StaticRoute.publicImmutable()`** - For versioned assets that never change ```dart - StaticRoute.publicImmutable(maxAge: 31536000) // 1 year, perfect for cache-busted files + StaticRoute.publicImmutable(maxAge: const Duration(years: 1)) // 1 year, perfect for cache-busted files ``` - **`StaticRoute.public()`** - For public assets with revalidation ```dart - StaticRoute.public(maxAge: 3600) // 1 hour, then revalidate + StaticRoute.public(maxAge: const Duration(hours: 1)) ``` - **`StaticRoute.privateNoCache()`** - For user-specific files ```dart @@ -84,7 +84,7 @@ pod.webServer.addRoute( StaticRoute.directory( staticDir, cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), + cacheControlFactory: StaticRoute.publicImmutable(maxAge: const Duration(years: 1)), ), '/static/**', ); @@ -111,28 +111,7 @@ The cache-busting system: - Works transparently - requesting `/static/logo@abc123.png` serves `/static/logo.png` -### Combining with cache control - -For optimal performance, combine cache-busting with aggressive caching: - -```dart -pod.webServer.addRoute( - StaticRoute.directory( - staticDir, - cacheBustingConfig: cacheBustingConfig, - cacheControlFactory: StaticRoute.publicImmutable(maxAge: 31536000), // 1 year - ), - '/static/**', -); -``` - -This approach ensures: - -- Browsers cache files for a long time (better performance) -- When files change, new hashes force cache invalidation -- No manual version management needed - -## Conditional requests (etags and last-modified) +## Conditional requests (`Etags` and `Last-Modified`) `StaticRoute` automatically supports HTTP conditional requests through Relic's `StaticHandler`. This provides efficient caching without transferring file From 0a874fb074e50c97b95b7f85970d028dd9799bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 13:26:51 +0100 Subject: [PATCH 36/39] fix: Prefer next over innerHandler for consistency --- .../06-concepts/18-webserver/04-middleware.md | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md index 8e60cfc9..c004d7ed 100644 --- a/docs/06-concepts/18-webserver/04-middleware.md +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -29,13 +29,13 @@ Middleware is a function that takes a `Handler` and returns a new `Handler`. Here's a simple logging middleware example: ```dart -Handler loggingMiddleware(Handler innerHandler) { +Handler loggingMiddleware(Handler next) { return (Request request) async { final start = DateTime.now(); print('→ ${request.method.name.toUpperCase()} ${request.url.path}'); // Call the next handler in the chain - final response = await innerHandler(request); + final response = await next(request); final duration = DateTime.now().difference(start); print('← ${response.statusCode} (${duration.inMilliseconds}ms)'); @@ -50,7 +50,7 @@ Handler loggingMiddleware(Handler innerHandler) { A common use case is validating API keys for protected routes: ```dart -Handler apiKeyMiddleware(Handler innerHandler) { +Handler apiKeyMiddleware(Handler next) { return (Request request) async { // Check for API key in header final apiKey = request.headers['X-API-Key']?.firstOrNull; @@ -69,7 +69,7 @@ Handler apiKeyMiddleware(Handler innerHandler) { } // Continue to the next handler - return await innerHandler(request); + return await next(request); }; } @@ -91,7 +91,7 @@ validation. Enable Cross-Origin Resource Sharing for your API: ```dart -Handler corsMiddleware(Handler innerHandler) { +Handler corsMiddleware(Handler next) { return (Request request) async { // Handle preflight requests if (request.method == Method.options) { @@ -109,7 +109,7 @@ Handler corsMiddleware(Handler innerHandler) { } // Process the request - final response = await innerHandler(request); + final response = await next(request); // Add CORS headers to response return response.copyWith( @@ -135,10 +135,10 @@ Error-handling middleware wraps all your routes and catches any exceptions they throw: ```dart -Handler errorHandlingMiddleware(Handler innerHandler) { +Handler errorHandlingMiddleware(Handler next) { return (Request request) async { try { - return await innerHandler(request); + return await next(request); } on FormatException catch (e) { // Handle JSON parsing errors return Response.badRequest( @@ -226,7 +226,7 @@ sequenceDiagram deactivate Logging ``` -## Request-scoped data with contextproperty +## Request-scoped data with `ContextProperty` Middleware often needs to pass computed data to downstream handlers. For example, a tenant identification middleware might extract the tenant ID from a @@ -249,7 +249,7 @@ request IDs, feature flags, or API version information extracted from headers. ::: -### Why use contextproperty? +### Why use `ContextProperty`? Since `Request` objects are immutable, you can't modify them directly. `ContextProperty` allows you to associate additional data with a request that @@ -264,7 +264,7 @@ include: - **Tenant identification** - Multi-tenant context from subdomains (when not part of user session) -### Creating a contextproperty +### Creating a `ContextProperty` Define a `ContextProperty` as a top-level constant or static field: @@ -290,7 +290,7 @@ downstream handlers: ```dart final requestIdProperty = ContextProperty('requestId'); -Handler requestIdMiddleware(Handler innerHandler) { +Handler requestIdMiddleware(Handler next) { return (Request request) async { // Generate a unique request ID for tracing final requestId = Uuid().v4(); @@ -302,7 +302,7 @@ Handler requestIdMiddleware(Handler innerHandler) { print('[$requestId] ${request.method} ${request.url.path}'); // Continue to next handler - final response = await innerHandler(request); + final response = await next(request); // Log the response print('[$requestId] Response: ${response.statusCode}'); @@ -344,7 +344,7 @@ class ApiRoute extends Route { } ``` -### Safe access with getornull +### Safe access with `getOrNull If a value might not be set, use `getOrNull()` to avoid exceptions: @@ -379,7 +379,7 @@ Here's a complete example showing tenant identification from subdomains: final tenantProperty = ContextProperty('tenant'); // Tenant identification middleware (extracts from subdomain) -Handler tenantMiddleware(Handler innerHandler) { +Handler tenantMiddleware(Handler next) { return (Request request) async { final host = request.headers.host; @@ -412,7 +412,7 @@ Handler tenantMiddleware(Handler innerHandler) { // Attach tenant to context tenantProperty[request] = tenant; - return await innerHandler(request); + return await next(request); }; } @@ -449,7 +449,7 @@ final requestIdProperty = ContextProperty('requestId'); final tenantProperty = ContextProperty('tenant'); final apiVersionProperty = ContextProperty('apiVersion'); -Handler requestContextMiddleware(Handler innerHandler) { +Handler requestContextMiddleware(Handler next) { return (Request request) async { // Generate and attach request ID final requestId = Uuid().v4(); @@ -466,7 +466,7 @@ Handler requestContextMiddleware(Handler innerHandler) { final apiVersion = request.headers['X-API-Version']?.firstOrNull ?? '1.0'; apiVersionProperty[request] = apiVersion; - return await innerHandler(request); + return await next(request); }; } From 37029a8c4eab1f2eb9acd5ad92fd3501c276c0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 13:26:51 +0100 Subject: [PATCH 37/39] ci: lint stuff --- docs/01-get-started/04-deployment.md | 2 +- docs/06-concepts/18-webserver/01-overview.md | 4 +++- docs/06-concepts/18-webserver/04-middleware.md | 2 +- docs/06-concepts/18-webserver/05-static-files.md | 7 +++++++ docs/06-concepts/18-webserver/06-typed-headers.md | 2 +- docs/06-concepts/18-webserver/07-server-side-html.md | 3 +++ package-lock.json | 11 +++++++---- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/01-get-started/04-deployment.md b/docs/01-get-started/04-deployment.md index dd2482c5..4c39970b 100644 --- a/docs/01-get-started/04-deployment.md +++ b/docs/01-get-started/04-deployment.md @@ -1,5 +1,5 @@ --- -sidebar_label: 5. Deploying Serverpod +sidebar_label: 4. Deploying Serverpod --- # Deploying Serverpod diff --git a/docs/06-concepts/18-webserver/01-overview.md b/docs/06-concepts/18-webserver/01-overview.md index 6ddf5ac4..13763796 100644 --- a/docs/06-concepts/18-webserver/01-overview.md +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -50,6 +50,7 @@ class ApiRoute extends Route { ``` The `handleCall()` method receives: + - **Session** - Access to your database, logging, and authenticated user - **Request** - The HTTP request with headers, body, and URL information @@ -87,6 +88,7 @@ Routes are matched in the order they were added. ## When to use what ### Rest apis → custom routes + For REST APIs, webhooks, or custom HTTP handlers, use custom `Route` classes: ```dart @@ -107,6 +109,7 @@ class UsersApiRoute extends Route { See [Routing](routing) for details. ### Static files → `StaticRoute` + For serving CSS, JavaScript, images, or other static assets: ```dart @@ -145,7 +148,6 @@ class UserRoute extends Route { ## Next steps - **[Routing](routing)** - Learn about HTTP methods, path parameters, and wildcards -- **[Modular Routes](modular-routes)** - Organize related endpoints with `injectIn()` - **[Middleware](middleware)** - Add cross-cutting functionality like error handling and logging - **[Static Files](static-files)** - Serve static assets with cache-busting - **[Typed Headers](typed-headers)** - Work with HTTP headers in a type-safe way diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md index c004d7ed..279f8713 100644 --- a/docs/06-concepts/18-webserver/04-middleware.md +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -344,7 +344,7 @@ class ApiRoute extends Route { } ``` -### Safe access with `getOrNull +### Safe access with `getOrNull` If a value might not be set, use `getOrNull()` to avoid exceptions: diff --git a/docs/06-concepts/18-webserver/05-static-files.md b/docs/06-concepts/18-webserver/05-static-files.md index ae546f8f..40e0598c 100644 --- a/docs/06-concepts/18-webserver/05-static-files.md +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -46,18 +46,25 @@ pod.webServer.addRoute( Available cache control factories: - **`StaticRoute.publicImmutable()`** - For versioned assets that never change + ```dart StaticRoute.publicImmutable(maxAge: const Duration(years: 1)) // 1 year, perfect for cache-busted files ``` + - **`StaticRoute.public()`** - For public assets with revalidation + ```dart StaticRoute.public(maxAge: const Duration(hours: 1)) ``` + - **`StaticRoute.privateNoCache()`** - For user-specific files + ```dart StaticRoute.privateNoCache() // Must revalidate every time ``` + - **`StaticRoute.noStore()`** - For sensitive content that shouldn't be cached + ```dart StaticRoute.noStore() // Never cache ``` diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md index 09f79000..e9e16b6d 100644 --- a/docs/06-concepts/18-webserver/06-typed-headers.md +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -87,7 +87,7 @@ Common response headers include: - Set appropriate cache headers for better performance - Use `secure: true` and `httpOnly: true` for sensitive cookies - Set proper `ContentDisposition` headers for file downloads -- Use `SameSite` cookie attribute for CSRF protection +- Use `SameSite` cookie attribute for CSRF protection ::: diff --git a/docs/06-concepts/18-webserver/07-server-side-html.md b/docs/06-concepts/18-webserver/07-server-side-html.md index f2e928ca..8b14b846 100644 --- a/docs/06-concepts/18-webserver/07-server-side-html.md +++ b/docs/06-concepts/18-webserver/07-server-side-html.md @@ -78,6 +78,7 @@ in Flutter. Serverpod provides several built-in widgets for common use cases: - **`ListWidget`** - Concatenates multiple widgets into a single response + ```dart return ListWidget(children: [ HeaderWidget(), @@ -87,11 +88,13 @@ Serverpod provides several built-in widgets for common use cases: ``` - **`JsonWidget`** - Renders JSON documents from serializable data structures + ```dart return JsonWidget({'status': 'success', 'data': myData}); ``` - **`RedirectWidget`** - Creates HTTP redirects to other URLs + ```dart return RedirectWidget('/new/location'); ``` diff --git a/package-lock.json b/package-lock.json index 85180334..d7830bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4345,6 +4345,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -6609,9 +6610,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "funding": [ { "type": "opencollective", @@ -18295,7 +18296,8 @@ "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "peer": true }, "node_modules/twitch-video-element": { "version": "0.1.5", @@ -19686,6 +19688,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 2cd964963e066c85f1421467e03f9b7d912fed0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 15:09:27 +0100 Subject: [PATCH 38/39] fix: Talk about why fallback and /** is not the same --- docs/06-concepts/18-webserver/02-routing.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/06-concepts/18-webserver/02-routing.md b/docs/06-concepts/18-webserver/02-routing.md index 2148cfcf..61236f65 100644 --- a/docs/06-concepts/18-webserver/02-routing.md +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -203,6 +203,12 @@ Future handleCall(Session session, Request request) async { } ``` +::: A note on literal vs dynamic segments + +Routing never does back-tracking, and adding a route with a literal segment always wins over dynamic segments such as wildcards. Say you have a route registered at `/**`, and another at `/a/b`, then `/a/c` will not be matched, because `a` wins over `**` on the first segment, and `c` doesn't match on the second (only `b` does). Some find this behavior surprising. What you probably meant was for the `/**` to act as a fallback route. You should use an explicit `fallback` route for that instead. + +::: + ## Fallback routes You can set a fallback route that handles requests when no other route matches: From 826bd8f914bda112a28f50fb0799e477949544c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20Overg=C3=A5rd=20Nielsen?= Date: Mon, 24 Nov 2025 15:14:05 +0100 Subject: [PATCH 39/39] fix: Remove stale links to modular-routes --- docs/06-concepts/18-webserver/04-middleware.md | 1 - docs/06-concepts/18-webserver/06-typed-headers.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/06-concepts/18-webserver/04-middleware.md b/docs/06-concepts/18-webserver/04-middleware.md index 279f8713..18cdd627 100644 --- a/docs/06-concepts/18-webserver/04-middleware.md +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -494,4 +494,3 @@ class DataRoute extends Route { - Serve [static files](static-files) with caching and compression - Use [typed headers](typed-headers) for type-safe header access -- Learn about [modular routes](modular-routes) for organizing complex APIs diff --git a/docs/06-concepts/18-webserver/06-typed-headers.md b/docs/06-concepts/18-webserver/06-typed-headers.md index e9e16b6d..2b3db1cf 100644 --- a/docs/06-concepts/18-webserver/06-typed-headers.md +++ b/docs/06-concepts/18-webserver/06-typed-headers.md @@ -232,4 +232,3 @@ return Response.ok( - Serve [static files](static-files) with caching and compression - Add [middleware](middleware) for cross-cutting concerns -- Learn about [modular routes](modular-routes) for organizing complex APIs