diff --git a/docs/06-concepts/06-database/09-pagination.md b/docs/06-concepts/06-database/09-pagination.md index fecd3965..e69de29b 100644 --- a/docs/06-concepts/06-database/09-pagination.md +++ b/docs/06-concepts/06-database/09-pagination.md @@ -1,110 +0,0 @@ -# Pagination - -Serverpod provides built-in support for pagination to help manage large datasets, allowing you to retrieve data in smaller chunks. Pagination is achieved using the `limit` and `offset` parameters. - -## Limit - -The `limit` parameter specifies the maximum number of records to return from the query. This is equivalent to the number of rows on a page. - -```dart -var companies = await Company.db.find( - session, - limit: 10, -); -``` - -In the example we fetch the first 10 companies. - -## Offset - -The `offset` parameter determines the starting point from which to retrieve records. It essentially skips the first `n` records. - -```dart -var companies = await Company.db.find( - session, - limit: 10, - offset: 30, -); -``` - -In the example we skip the first 30 rows and fetch the 31st to 40th company. - -## Using limit and offset for pagination - -Together, `limit` and `offset` can be used to implement pagination. - -```dart -int page = 3; -int companiesPerPage = 10; - -var companies = await Company.db.find( - session, - orderBy: (t) => t.id, - limit: companiesPerPage, - offset: (page - 1) * companiesPerPage, -); -``` - -In the example we fetch the third page of companies, with 10 companies per page. - -### Tips - -1. **Performance**: Be aware that while `offset` can help in pagination, it may not be the most efficient way for very large datasets. Using an indexed column to filter results can sometimes be more performant. -2. **Consistency**: Due to possible data changes between paginated requests (like additions or deletions), the order of results might vary. It's recommended to use an `orderBy` parameter to ensure consistency across paginated results. -3. **Page numbering**: Page numbers usually start from 1. Adjust the offset calculation accordingly. - -## Cursor-based pagination - -A limit-offset pagination may not be the best solution if the table is changed frequently and rows are added or removed between requests. - -Cursor-based pagination is an alternative method to the traditional limit-offset pagination. Instead of using an arbitrary offset to skip records, cursor-based pagination uses a unique record identifier (a _cursor_) to mark the starting or ending point of a dataset. This approach is particularly beneficial for large datasets as it offers consistent and efficient paginated results, even if the data is being updated frequently. - -### How it works - -In cursor-based pagination, the client provides a cursor as a reference point, and the server returns data relative to that cursor. This cursor is usually an `id`. - -### Implementing cursor-based pagination - -1. **Initial request**: - For the initial request, where no cursor is provided, retrieve the first `n` records: - - ```dart - int recordsPerPage = 10; - - var companies = await Company.db.find( - session, - orderBy: (t) => t.id, - limit: recordsPerPage, - ); - ``` - -2. **Subsequent requests**: - For the subsequent requests, use the cursor (for example, the last `id` from the previous result) to fetch the next set of records: - - ```dart - int cursor = lastCompanyIdFromPreviousPage; // This is typically sent by the client - - var companies = await Company.db.find( - session, - where: Company.t.id > cursor, - orderBy: (t) => t.id, - limit: recordsPerPage, - ); - ``` - -3. **Returning the cursor**: - When returning data to the client, also return the cursor, so it can be used to compute the starting point for the next page. - - ```dart - return { - 'data': companies, - 'lastCursor': companies.last.id, - }; - ``` - -### Tips - -1. **Choosing a cursor**: While IDs are commonly used as cursors, timestamps or other unique, sequentially ordered fields can also serve as effective cursors. -2. **Backward pagination**: To implement backward pagination, use the first item from the current page as the cursor and adjust the query accordingly. -3. **Combining with sorting**: Ensure the field used as a cursor aligns with the sorting order. For instance, if you're sorting data by a timestamp in descending order, the cursor should also be based on the timestamp. -4. **End of data**: If the returned data contains fewer items than the requested limit, it indicates that you've reached the end of the dataset. diff --git a/docs/06-concepts/18-webserver.md b/docs/06-concepts/18-webserver.md deleted file mode 100644 index ed802d04..00000000 --- a/docs/06-concepts/18-webserver.md +++ /dev/null @@ -1,74 +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 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. - -::: - -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. - -## Creating new routes and components - -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 ComponentRoute class and implementing the build method. - -```dart -class MyRoute extends ComponentRoute { - @override - Future build(Session session, HttpRequest request) async { - return MyPageComponent(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. - -```dart -class MyPageComponent extends Component { - MyPageComponent({String title}) : super(name: 'my_page') { - values = { - 'title': title, - }; - } -} -``` - -:::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. - -::: - -## Special components 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. - -- `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. - -To serve a static directory, use the `RouteStaticDirectory` class. Serverpod will set the correct content types for most file types automatically. - -:::caution - -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. - -::: - -## 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. 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..ad0dce91 --- /dev/null +++ b/docs/06-concepts/18-webserver/01-overview.md @@ -0,0 +1,154 @@ +# 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 + +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. + +## 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)** - Match requests to handlers by method and URL pattern +- **[Request Data](request-data)** - Access path parameters, query parameters, headers, and body +- **[Middleware](middleware)** - Intercept and transform requests and responses +- **[Static Files](static-files)** - Serve static assets +- **[Server-side HTML](server-side-html)** - Render HTML dynamically on the server 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..f69e6b56 --- /dev/null +++ b/docs/06-concepts/18-webserver/02-routing.md @@ -0,0 +1,215 @@ +# 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. + +## Route classes + +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, + ), + ); + } +} +``` + +You need to register your custom routes with the built-in router under a given path: + +```dart +// Register the route +pod.webServer.addRoute(ApiRoute(), '/api/data'); +``` + +:::info + +The examples in this documentation omit error handling for brevity. + +::: + +## Http methods + +Routes can specify which HTTP methods they respond to using the `methods` +parameter. + +```dart +class UserRoute extends Route { + UserRoute() : super( + methods: {Method.get, Method.post, Method.delete}, + ); + // ... +} +``` + +## Path parameters + +Define path parameters in your route pattern using the `:paramName` syntax: + +```dart +pod.webServer.addRoute(UserRoute(), '/api/users/:id'); +// Matches: /api/users/123, /api/users/456, etc. +``` + +You can use multiple path parameters in a single route: + +```dart +pod.webServer.addRoute(route, '/:userId/posts/:postId'); +// Matches: /123/posts/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 Tail matches + +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. + +::: + +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.remainingPath; + + // 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(); +``` + +:::tip Advanced: Grouping routes into modules + +As your web server grows, managing dozens of individual route registrations can +become unwieldy. You can group related endpoints into reusable modules by +overriding the `injectIn()` method. This lets you register multiple handler +functions instead of implementing a single `handleCall()` method. + +Here's an example: + +```dart +class UserCrudModule extends Route { + @override + void injectIn(RelicRouter router) { + // Register multiple routes with path parameters + router + ..get('/', _list) + ..get('/:id', _get); + } + + // 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, + ), + ); + } + + static const _idParam = IntPathParam(#id); + Future _get(Request request) async { + int userId = request.pathParameters.get(_idParam); + final session = await 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, + ), + ); + } +} + +// Register the entire CRUD module under /api/users +pod.webServer.addRoute(UserCrudModule(), '/api/users'); +``` + +This creates `GET /api/users` and `GET /api/users/:id` endpoints. + +Note that handlers receive only a `Request` parameter. To access the `Session`, +use `request.session` (unlike `Route.handleCall()` which receives both as +explicit parameters). + +::: + +## Next steps + +- **[Request Data](request-data)** - Access path parameters, query parameters, headers, and body +- **[Middleware](middleware)** - Intercept and transform requests and responses +- **[Static Files](static-files)** - Serve static assets +- **[Server-side HTML](server-side-html)** - Render HTML dynamically on the server diff --git a/docs/06-concepts/18-webserver/03-request-data.md b/docs/06-concepts/18-webserver/03-request-data.md new file mode 100644 index 00000000..93da0210 --- /dev/null +++ b/docs/06-concepts/18-webserver/03-request-data.md @@ -0,0 +1,108 @@ +# Request Data + +Once a route matches, you'll need to extract data from the request. Relic +provides type-safe accessors for path parameters, query parameters, headers, +and the request body. + +## Path parameters + +Access path parameters using typed `PathParam` accessors: + +```dart +class UserRoute extends Route { + static const _idParam = IntPathParam(#id); + + @override + Future handleCall(Session session, Request request) async { + int userId = request.pathParameters.get(_idParam); + 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, + ), + ); + } +} +``` + +The `IntPathParam` combines the symbol (`#id`) with a parser, throwing if the +parameter is missing or not a valid integer. You can also access raw unparsed +values with `request.pathParameters.raw[#id]`. + +## Query parameters + +Query parameters (`?key=value`) use the same typed accessor pattern: + +```dart +class SearchRoute extends Route { + static const _pageParam = IntQueryParam('page'); + + @override + Future handleCall(Session session, Request request) async { + int page = request.queryParameters.get(_pageParam); // typed access + String? query = request.queryParameters.raw['query']; // raw access + // ... + } +} +``` + +## Headers + +Access headers through `request.headers` with type-safe getters for standard +HTTP headers: + +```dart +@override +Future handleCall(Session session, Request request) async { + // Type-safe accessors for standard headers + final userAgent = request.headers.userAgent; + final contentLength = request.headers.contentLength; + final auth = request.headers.authorization; + + // Raw access for custom headers + final apiKey = request.headers['X-API-Key']?.first; + + // ... +} +``` + +## Body + +Read the request body using the appropriate method for your content type: + +```dart +@override +Future handleCall(Session session, Request request) async { + // Read as string (for JSON, form data, etc.) + final body = await request.readAsString(); + final data = jsonDecode(body); + + // Or read as stream for large uploads + final stream = request.read(); + await for (final chunk in stream) { + // Process chunk + } + + // ... +} +``` + +:::warning +The body can only be read once. Attempting to read it again will throw a +`StateError`. +::: + +For more details on typed parameters and headers, see the +[Relic documentation](https://docs.dartrelic.dev/). + +## Next steps + +- **[Middleware](middleware)** - Intercept and transform requests and responses +- **[Static Files](static-files)** - Serve static assets +- **[Server-side HTML](server-side-html)** - Render HTML dynamically on the server 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..0709fccf --- /dev/null +++ b/docs/06-concepts/18-webserver/04-middleware.md @@ -0,0 +1,201 @@ +# 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 below `/path` +pod.webServer.addMiddleware(myMiddleware, '/path'); +``` + +## Creating custom middleware + +Middleware is a function that takes a `Handler` and returns a new `Handler`. +Here's a simple example that validates API keys for protected routes: + +```dart +Handler apiKeyMiddleware(Handler next) { + 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 next(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. + +::: + +## 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(rateLimitMiddleware, '/api/users'); // Executes last for /api (inner) +pod.webServer.addMiddleware(apiKeyMiddleware, '/api'); // Executes first for /api (outer) +``` + +For a request to `/api/users/list`, the execution order is: + +```mermaid +sequenceDiagram + participant Client + participant Auth as apiKeyMiddleware + participant RateLimit as rateLimitMiddleware + participant Handler as Route Handler + + Client->>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-->>Client: Response +``` + +## Request-scoped data + +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. For more details, see the [Relic documentation](https://docs.dartrelic.dev/). + +:::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. + +::: + +### Creating a `ContextProperty` + +Define a `ContextProperty` as a top-level static field: + +```dart +// Define a private context property. +final _tenantProperty = ContextProperty('tenant'); + +// Create a public getter extension to allow handlers and other middleware to +// read, but not modify the context property. +extension tenantPropertyEx on Request { + String get tenant => _tenantProperty.get(this); // get throw on null, [] doesn't +} +``` + +### Setting values in middleware + +Middleware can set values on the context property, making them available to all +downstream handlers: + +```dart +// in same file + +// Tenant identification middleware (extracts from subdomain) +Handler tenantMiddleware(Handler next) { + return (Request request) async { + final host = request.headers.host; + + // Validate tenant exists (implement your own logic) + final session = request.session; + final tenant = await extractAndValidateTenant(session, host); + + if (tenant == null) { + return Response.notFound( + body: Body.fromString('Tenant not found'), + ); + } + + // Attach tenant to context + _tenantProperty[request] = tenant; + + return await next(request); + }; +} +``` + +### Accessing values in routes + +Route handlers can retrieve the value from the context property: + +```dart +// Routes automatically have access to the tenant +class TenantDataRoute extends Route { + @override + Future handleCall(Session session, Request request) async { + final tenant = request.tenant; // using the previously defined extension + + // 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, + ), + ); + } +} +``` + +## Next steps + +- **[Request Data](request-data)** - Access path parameters, query parameters, headers, and body +- **[Static Files](static-files)** - Serve static assets +- **[Server-side HTML](server-side-html)** - Render HTML dynamically on the server 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..780f83bc --- /dev/null +++ b/docs/06-concepts/18-webserver/05-static-files.md @@ -0,0 +1,143 @@ +# 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: const Duration(years: 1)), + ), + '/static/**', +); +``` + +## 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. For more details, see the [Relic documentation](https://docs.dartrelic.dev/). + +```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: const Duration(years: 1)), + ), + '/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` + +## 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. + +For dynamic content that changes per request, see [Server-side HTML](server-side-html). 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..0416d78b --- /dev/null +++ b/docs/06-concepts/18-webserver/07-server-side-html.md @@ -0,0 +1,124 @@ +# Server-Side HTML + +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` (which extends `WebWidget`) 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); + } +} +``` + +:::info Alternative + +If you prefer [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. + +::: + +## 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..7999273f --- /dev/null +++ b/docs/06-concepts/18-webserver/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Web server", + "collapsed": true +} diff --git a/docs/08-upgrading/01-upgrade-to-three.md b/docs/08-upgrading/01-upgrade-to-three.md index 757b91e0..d3f0f6c8 100644 --- a/docs/08-upgrading/01-upgrade-to-three.md +++ b/docs/08-upgrading/01-upgrade-to-three.md @@ -1,216 +1,94 @@ # 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]); -``` - -### 5. Update Route Registration - -Update your route registration to use the renamed route classes: - -**Before:** - -```dart -pod.webServer.addRoute(RouteRoot(), '/'); -pod.webServer.addRoute(RouteRoot(), '/index.html'); +pod.webServer.addRoute( + StaticRoute.directory(Directory('static')), + '/static/**', +); ``` -**After:** +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(RootRoute(), '/'); -pod.webServer.addRoute(RootRoute(), '/index.html'); +// Example with immutable public caching +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 3600), + ), + '/static/**', +); ``` -### 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: +Other available cache control factory methods: -**Before:** - -```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(); - } -} -``` +- `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 -**After:** +You can also provide a custom factory function: ```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/**', +); ``` diff --git a/docs/08-upgrading/tmp b/docs/08-upgrading/tmp new file mode 100644 index 00000000..3180cba3 --- /dev/null +++ b/docs/08-upgrading/tmp @@ -0,0 +1,93 @@ +# Upgrade to 3.0 + +## Web Server Changes + +### Widget Class Naming Updates + +In Serverpod 3.0, the web server widget classes have been reorganized for better clarity: + +- 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 + +The `WidgetRoute` class remains unchanged and continues to be the base class for web routes. + +**Recommended migration:** + +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}; + } +} + +// New (recommended) +class MyPageWidget extends TemplateWidget { + MyPageWidget({required String title}) : super(name: 'my_page') { + values = {'title': title}; + } +} +``` + +### Static Route Updates + +The `RouteStaticDirectory` class has been deprecated in favor of `StaticRoute.directory()`: + +**Before:** + +```dart +pod.webServer.addRoute( + RouteStaticDirectory( + serverDirectory: 'static', + basePath: '/', + ), + '/static/**', +); +``` + +**After:** + +```dart +pod.webServer.addRoute( + StaticRoute.directory(Directory('static')), + '/static/**', +); +``` + +The new `StaticRoute` provides better cache control options. You can use the built-in static helper methods for common caching scenarios: + +```dart +// Example with immutable public caching +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: StaticRoute.publicImmutable(maxAge: 3600), + ), + '/static/**', +); +``` + +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 + +You can also provide a custom factory function: + +```dart +pod.webServer.addRoute( + StaticRoute.directory( + Directory('static'), + cacheControlFactory: (ctx, fileInfo) => CacheControlHeader( + publicCache: true, + maxAge: 3600, + immutable: true, + ), + ), + '/static/**', +); +``` diff --git a/package-lock.json b/package-lock.json index b6af4b1b..897313b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6610,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",