diff --git a/docs/05-build-your-first-app/01-creating-endpoints.md b/docs/05-build-your-first-app/01-creating-endpoints.md index 8b09a4ea..d7629bd2 100644 --- a/docs/05-build-your-first-app/01-creating-endpoints.md +++ b/docs/05-build-your-first-app/01-creating-endpoints.md @@ -1,53 +1,74 @@ --- -sidebar_label: Create your first endpoint +title: Create your first endpoint sidebar_class_name: sidebar-icon-get-started-step-1 slug: /get-started/creating-endpoints +description: Build a Serverpod endpoint that turns a list of ingredients into an AI-generated recipe with Gemini, and call it from your Flutter app. --- + + # Create your first endpoint -With Serverpod, calling an endpoint method in your server is as simple as calling a local method in your app. Let's create your first custom endpoint method and call it from the Flutter app. In this example, you will create a method that generates recipes from ingredients you may have in your fridge. Your server will talk with Google's Gemini API to make this magic happen. You will then call your endpoint method from the Flutter app and display the recipe. +You'll build a recipe generator: a Serverpod endpoint that takes a list of ingredients, asks Google's Gemini API for a recipe, and returns it to your Flutter app. Along the way you'll see that calling your server is as simple as calling a local method. + +Prefer to have an AI agent build an app for you? Follow the [Quickstart](../04-get-started/02-quickstart.md) instead. This guide takes the hands-on path: you'll build the recipe app yourself, so you understand each piece. :::info -On the server, you can do things you don't want to do in the app, like calling an API secured by a secret key or accessing a database. The server can also do things that are impossible in the app, like sending push notifications or emails. +The server is the right place for work you can't or shouldn't do in the Flutter app, such as calling an API secured by a secret key, accessing a database, or sending push notifications and emails. Here, it keeps your Gemini API key off the client. ::: -## Create a new project +## Before you start + +- [Serverpod installed](../04-get-started/01-installation.md). Run `serverpod version` to confirm it works. +- A free Gemini API key. On [Google AI Studio](https://aistudio.google.com/app/apikey), sign in with a Google account and click **Create API key**. -Use the `serverpod create` command to create a new project. This command will generate a new project with a server, a client, and a Flutter app. +## Create the project + +Use `serverpod create` to generate a new project with a server, a client, and a Flutter app: ```bash -serverpod create magic_recipe +$ serverpod create magic_recipe ``` -:::tip -Always open the root directory of the project in your IDE. This will make it easier to navigate between the server and app packages. It will also prevent your analyzer from going out of sync when you generate code. -::: +The command is interactive. Step through the prompts, accepting the defaults. -### Add the Gemini API to your project +Open the project's **root** folder (`magic_recipe`) in your editor, not one of the sub-packages. This keeps the analyzer in sync when code is generated and makes it easy to move between the server and app. -To generate our recipes, we will use Google's free Gemini API. To use it, you must create an API key on [this page](https://aistudio.google.com/app/apikey). It's free, but you have to sign in with your Google account. Add your key to the `config/passwords.yaml` file in your project's server package. Git ignores this file, so you can safely add your API key here. +### Add your Gemini API key + +Gemini is Google's generative AI model. Your server sends it the ingredients and gets a recipe back, and the API key authenticates those calls. + +Add your key to `config/passwords.yaml` in the server package. Git ignores this file, so your key stays out of version control. ```yaml -# config/passwords.yaml -# This file is not included in the git repository. You can safely add your API key here. -# The API key is used to authenticate with the Gemini API. +# magic_recipe_server/config/passwords.yaml development: geminiApiKey: '--- Your Gemini Api Key ---' ``` -Next, we add the Dartantic AI package as a dependency to our server. This package provides a convenient interface for working with different AI providers, including Google's Gemini API. +Then add the Dartantic AI package to the server. It provides a single interface for talking to AI providers, including Gemini: ```bash $ cd magic_recipe_server $ dart pub add dartantic_ai ``` -## Create a new endpoint +## Start the app + +From the project's root folder, start everything with one command: + +```bash +$ serverpod start +``` + +`serverpod start` generates your code, starts the server with its built-in PostgreSQL database (no Docker required), and opens the Flutter app in Chrome. The app that opens is the default Serverpod starter: enter your name, tap **Send to Server**, and the server responds with a greeting. + +Leave `serverpod start` running. It watches your project, so every time you save a file it regenerates the necessary code and hot reloads the app. You'll rely on this for the rest of the guide instead of restarting anything by hand. -Create a new file in `magic_recipe_server/lib/src/recipes/` called `recipe_endpoint.dart`. This is where you will define your endpoint and its methods. With Serverpod, you can choose any directory structure you want to use. E.g., you could also use `src/endpoints/` if you want to go layer first or `src/features/recipes/` if you have many features. +## Add an endpoint + +Server endpoints live in `lib/src//`, like the `greetings` endpoint the template generated. Create a file at `magic_recipe_server/lib/src/recipes/recipe_endpoint.dart`: - ```dart import 'package:dartantic_ai/dartantic_ai.dart'; import 'package:serverpod/serverpod.dart'; @@ -58,8 +79,6 @@ import 'package:serverpod/serverpod.dart'; class RecipeEndpoint extends Endpoint { /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { - // Serverpod automatically loads your passwords.yaml file and makes the - // passwords available in the session.passwords map. final geminiApiKey = session.passwords['geminiApiKey']; if (geminiApiKey == null) { throw Exception('Gemini API key not found'); @@ -71,8 +90,6 @@ class RecipeEndpoint extends Endpoint { chatModelName: 'gemini-2.5-flash-lite', ); - // A prompt to generate a recipe, the user will provide a free text input - // with the ingredients. final prompt = 'Generate a recipe using the following ingredients: $ingredients. ' 'Always put the title of the recipe in the first line, followed by the ' @@ -83,7 +100,6 @@ class RecipeEndpoint extends Endpoint { final responseText = response.output; - // Check if the response is empty. if (responseText.isEmpty) { throw Exception('No response from Gemini API'); } @@ -92,46 +108,39 @@ class RecipeEndpoint extends Endpoint { } } ``` - + +The endpoint reads your Gemini key from `session.passwords`, which Serverpod populates from the `passwords.yaml` file you edited earlier. :::info -For methods to be recognized by Serverpod, they need to return a typed `Future` or `Stream`, where the type must be `void` `bool`, `int`, `double`, `String`, `UuidValue`, `Duration`, `DateTime`, `ByteData`, `Uri`, `BigInt`, or a [serializable model](../06-concepts/02-models/01-models.md). The first parameter must be a `Session` object. You can pass any serializable types as parameters, and even use `List`, `Map`, `Set` or Dart records as long as they are typed. +Endpoint methods take a `Session` as their first parameter and return a typed `Future` or `Stream`. You can pass and return primitive types or any [model defined in a `.spy.yaml` file](../06-concepts/02-models/01-models.md). The class name's `Endpoint` suffix is dropped on the client, so `RecipeEndpoint` is called via `client.recipe`. See [How it works](../04-get-started/03-how-it-works.md) for how that call reaches the server. ::: -Now, you need to generate the code for your new endpoint. You do this by running `serverpod generate` in the server directory of your project: +Save the file. Because `serverpod start` is watching, it regenerates the client bindings for `generateRecipe` automatically. You'll see it run in the terminal. -```bash -$ cd magic_recipe_server -$ serverpod generate -``` +## Call it from your app -`serverpod generate` will create bindings for the endpoint and register them in the server's `generated/protocol.dart` file. It will also generate the required client code so that you can call your new `generateRecipe` method from your app. - -:::note -When writing server-side code, in most cases, you want it to be _stateless_. This means you avoid using global or static variables. Instead, think of each endpoint method as a function that does stuff in a sub-second timeframe and returns data or a status messages to your client. If you want to run more complex computations, you can return a `Stream` to yield progress updates as your task progresses. -::: +Your app's UI lives in `magic_recipe_flutter/lib/screens/`, where the template already added a `GreetingsScreen`. Add a recipe screen alongside it. -## Call the endpoint from the client +Create `magic_recipe_flutter/lib/screens/recipe_screen.dart`: -Now that you have created the endpoint, you can call it from the Flutter app. Do this in the `magic_recipe_flutter/lib/main.dart` file. Since the generated template uses a StatelessWidget for `MyApp`, you will need to introduce a StatefulWidget called `MyHomePage` to manage the state of the app. Replace the `MyApp` widget with the following code (feel free to just copy and paste): - - ```dart -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +import 'package:flutter/material.dart'; + +import '../main.dart'; +import 'greetings_screen.dart'; - final String title; +class RecipeScreen extends StatefulWidget { + const RecipeScreen({super.key}); @override - MyHomePageState createState() => MyHomePageState(); + State createState() => _RecipeScreenState(); } -class MyHomePageState extends State { - /// Holds the last result or null if no result exists yet. +class _RecipeScreenState extends State { + /// Holds the last result, or null if there's no result yet. String? _resultMessage; - /// Holds the last error message that we've received from the server or null - /// if no error exists yet. + /// Holds the last error message, or null if there's no error yet. String? _errorMessage; final _textEditingController = TextEditingController(); @@ -140,29 +149,23 @@ class MyHomePageState extends State { void _callGenerateRecipe() async { try { - // Reset the state. setState(() { _errorMessage = null; _resultMessage = null; _loading = true; }); - // Call our `generateRecipe` method on the server. final result = await client.recipe.generateRecipe( _textEditingController.text, ); - // Update the state with the recipe we got from the server. setState(() { - _errorMessage = null; _resultMessage = result; _loading = false; }); } catch (e) { - // If something goes wrong, set an error message. setState(() { _errorMessage = '$e'; - _resultMessage = null; _loading = false; }); } @@ -170,76 +173,63 @@ class MyHomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: TextField( - controller: _textEditingController, - decoration: const InputDecoration( - hintText: 'Enter your ingredients', - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: ElevatedButton( - onPressed: _loading ? null : _callGenerateRecipe, - child: _loading - ? const Text('Loading...') - : const Text('Generate Recipe'), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _textEditingController, + decoration: const InputDecoration( + hintText: 'Enter your ingredients', ), - Expanded( - child: SingleChildScrollView( - child: ResultDisplay( - resultMessage: _resultMessage, - errorMessage: _errorMessage, - ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loading ? null : _callGenerateRecipe, + child: _loading + ? const Text('Loading...') + : const Text('Generate Recipe'), + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: ResultDisplay( + resultMessage: _resultMessage, + errorMessage: _errorMessage, ), ), - ], - ), + ), + ], ), ); } } ``` - -## Run the app - -:::tip -Before you start your server, ensure no other Serverpod server is running. Also, ensure that Docker containers from other Serverpod projects aren't running to avoid port conflicts. You can see and stop containers in the Docker Desktop app. -::: +`client` comes from `main.dart`, where the template already wired it to talk to your server, and `ResultDisplay` is reused from `greetings_screen.dart`. -Let's try our new recipe app! First, start the server: +Now show the recipe screen instead of the greeting demo. In `magic_recipe_flutter/lib/main.dart`, add the import: -```bash -$ cd magic_recipe_server -$ docker compose up -d -$ dart bin/main.dart --apply-migrations +```dart +import 'screens/recipe_screen.dart'; ``` -Now, you can start the Flutter app: +Then, in the `MyHomePage` widget, change the body from `GreetingsScreen` to `RecipeScreen`: -```bash -$ cd magic_recipe_flutter -$ flutter run -d chrome +```dart + body: const RecipeScreen(), ``` -This will start the Flutter app in your browser: +Save. UI edits like this would normally hot reload, but adding the endpoint also changed the generated client. The app's `client` is created once in `main()`, which only re-runs on a restart, so the app needs a hot restart to pick up the new `client.recipe` endpoint. -![Example Flutter App](/img/getting-started/endpoint-chrome-result.png) +In the `serverpod start` terminal: + +- Press **R** to hot restart. -Try out the app by clicking the button to get a new recipe. The app will call the endpoint on the server and display the result in the app. +Then enter some ingredients and tap **Generate Recipe**. The app calls your endpoint and displays the result: + +![Example Flutter App](/img/getting-started/endpoint-chrome-result.png) ## Next steps -For now, you are just returning a `String` to the client. In the next section, you will create a custom data model to return structured data. Serverpod makes it easy by handling all the serialization and deserialization for you. +You've created an endpoint and called it from your app, passing a string back and forth. Next, you'll return structured data using a Serverpod model, with serialization handled for you. Leave `serverpod start` running; you'll keep building on the same app. diff --git a/docs/05-build-your-first-app/02-models-and-data.md b/docs/05-build-your-first-app/02-models-and-data.md index 76e7afb0..c9dcda40 100644 --- a/docs/05-build-your-first-app/02-models-and-data.md +++ b/docs/05-build-your-first-app/02-models-and-data.md @@ -1,18 +1,22 @@ --- -sidebar_label: Create data models +title: Create data models sidebar_class_name: sidebar-icon-get-started-step-2 slug: /get-started/models-and-data +description: Define a Serverpod data model so your endpoint returns typed, structured data, with serialization between server and client generated for you. --- + + # Create data models -Serverpod ships with a powerful data modeling system that uses easy-to-read definition files in YAML. It generates Dart classes with all the necessary code to serialize and deserialize the data and connect to the database. This allows you to define your data models for the server and the app in one place, eliminating any inconsistencies. The models give you fine-grained control over the visibility of properties and how they interact with each other. +On the [previous page](./01-creating-endpoints.md) your endpoint returned a plain string. Here you'll define a `Recipe` model so the server returns structured, typed data instead. You define the model once in YAML, and Serverpod generates the Dart class plus all the serialization needed between server and client. + +Keep `serverpod start` running from the previous page. -## Create a new model +## Define a model -Models files can be placed anywhere in the server's `lib` directory. We will create a new model file called `recipe.spy.yaml` in the `magic_recipe_server/lib/src/recipes/` directory. Use the `.spy.yaml` extension to indicate that this is a _serverpod YAML_ file. +Serverpod models are declared in `.spy.yaml` files anywhere under the server's `lib` directory. Create `magic_recipe_server/lib/src/recipes/recipe.spy.yaml`: - ```yaml ### Our AI generated Recipe class: Recipe @@ -23,436 +27,66 @@ fields: text: String ### The date the recipe was created date: DateTime - ### The ingredients the user has passed in + ### The ingredients the user passed in ingredients: String ``` - - -You can use most primitive Dart types here or any other models you have specified in other YAML files. You can also use typed `List`, `Map`, or `Set`. For detailed information, see [Working with models](../06-concepts/02-models/01-models.md) -## Generate the code +Save the file. `serverpod start` regenerates the `Recipe` class for both the server and the client. -To generate the code for the model, run the `serverpod generate` command in your server directory: +:::info +Fields can be primitive types, other models, or a typed `List`, `Map`, or `Set`. See [Working with models](../06-concepts/02-models/01-models.md) for the full set of options. +::: -```bash -$ cd magic_recipe_server -$ serverpod generate -``` - -This will generate the code for the model and create a new file called `recipe.dart` in the `lib/src/generated` directory. It will also update the client code in `magic_recipe/magic_recipe_client` so you can use it in your Flutter app. +## Return the model from your endpoint -## Use the model in the server +Now update `recipe_endpoint.dart` to return a `Recipe` instead of a `String`. -Now that you have created the model, you can use it in your server code. Let's update the `lib/src/recipes/recipe_endpoint.dart` file to make the `generateRecipe` method return a `Recipe` object instead of a string. +Import the generated models at the top of the file: - ```dart -// ... -import 'package:magic_recipe_server/src/generated/protocol.dart'; -// ... -class RecipeEndpoint extends Endpoint { - /// Pass in a string containing the ingredients and get a recipe back. - Future generateRecipe(Session session, String ingredients) async { -// ... - final recipe = Recipe( - author: 'Gemini', - text: responseText, - date: DateTime.now(), - ingredients: ingredients, - ); - - return recipe; - } -} +import '../generated/protocol.dart'; ``` - - -
-Click to see the full code -

+Change the method's return type from `Future` to `Future`: - ```dart -import 'dart:async'; - -import 'package:dartantic_ai/dartantic_ai.dart'; -import 'package:magic_recipe_server/src/generated/protocol.dart'; -import 'package:serverpod/serverpod.dart'; - -/// This is the endpoint that will be used to generate a recipe using the -/// Google Gemini API. It extends the Endpoint class and implements the -/// generateRecipe method. -class RecipeEndpoint extends Endpoint { - /// Pass in a string containing the ingredients and get a recipe back. Future generateRecipe(Session session, String ingredients) async { - // Serverpod automatically loads your passwords.yaml file and makes the passwords available - // in the session.passwords map. - final geminiApiKey = session.passwords['geminiApiKey']; - if (geminiApiKey == null) { - throw Exception('Gemini API key not found'); - } - - // Configure the Dartantic AI agent for Gemini before sending the prompt. - final agent = Agent.forProvider( - GoogleProvider(apiKey: geminiApiKey), - chatModelName: 'gemini-2.5-flash-lite', - ); - - // A prompt to generate a recipe, the user will provide a free text input with the ingredients. - final prompt = - 'Generate a recipe using the following ingredients: $ingredients, always put the title ' - 'of the recipe in the first line, and then the instructions. The recipe should be easy ' - 'to follow and include all necessary steps. Please provide a detailed recipe.'; - - final response = await agent.send(prompt); - final responseText = response.output; +``` - // Check if the response is empty. - if (responseText.isEmpty) { - throw Exception('No response from Gemini API'); - } +Then replace `return responseText;` with a constructed `Recipe`: - final recipe = Recipe( +```dart + return Recipe( author: 'Gemini', text: responseText, date: DateTime.now(), ingredients: ingredients, ); - - return recipe; - } -} ``` - -

-
+Save the file. The client regenerates so `generateRecipe` now returns a `Recipe`. -## Use the model in the app +## Show it in your app -First, we need to update our generated client by running `serverpod generate`. +In `recipe_screen.dart`, the call now returns a `Recipe` object, so assigning `result` to a string no longer compiles. `_resultMessage` itself stays a `String?`; you just format the `Recipe`'s fields into it. In `_callGenerateRecipe`, update the success `setState`: -```bash -$ cd magic_recipe_server -$ serverpod generate -``` - -Now that we have created the `Recipe` model we can use it in the app. We will do this in the `_callGenerateRecipe` method of the `magic_recipe_flutter/lib/main.dart` file. Let's update our `RecipeWidget` so that it displays the author and year of the recipe in addition to the recipe itself. - - -```dart -void _callGenerateRecipe() async { -// ... - - // Update the state with the recipe we got from the server. - setState(() { - _errorMessage = null; - - // Here we read the properties from our new Recipe model. - _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; - _loading = false; - }); - -// ... - } -} -``` - - -
- -Click to see the full code -

- - ```dart -import 'package:magic_recipe_client/magic_recipe_client.dart'; -import 'package:flutter/material.dart'; -import 'package:serverpod_flutter/serverpod_flutter.dart'; -import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; - -/// Sets up a global client object that can be used to talk to the server from -/// anywhere in our app. The client is generated from your server code -/// and is set up to connect to a Serverpod running on a local server on -/// the default port. You will need to modify this to connect to staging or -/// production servers. -/// In a larger app, you may want to use the dependency injection of your choice -/// instead of using a global client object. This is just a simple example. -late final Client client; - -late String serverUrl; - -void main() { - // When you are running the app on a physical device, you need to set the - // server URL to the IP address of your computer. You can find the IP - // address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux. - // You can set the variable when running or building your app like this: - // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` - const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); - final serverUrl = serverUrlFromEnv.isEmpty - ? 'http://$localhost:8080/' - : serverUrlFromEnv; - - client = Client(serverUrl) - ..connectivityMonitor = FlutterConnectivityMonitor() - ..authSessionManager = FlutterAuthSessionManager(); - - client.auth.initialize(); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Serverpod Demo', - theme: ThemeData(primarySwatch: Colors.blue), - home: const MyHomePage(title: 'Serverpod Example'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - final String title; - - @override - MyHomePageState createState() => MyHomePageState(); -} - -class MyHomePageState extends State { - /// Holds the last result or null if no result exists yet. - String? _resultMessage; - - /// Holds the last error message that we've received from the server or null - /// if no error exists yet. - String? _errorMessage; - - final _textEditingController = TextEditingController(); - - bool _loading = false; - - void _callGenerateRecipe() async { - try { - // Reset the state. - setState(() { - _errorMessage = null; - _resultMessage = null; - _loading = true; - }); - - // Call our `generateRecipe` method on the server. - final result = await client.recipe.generateRecipe( - _textEditingController.text, - ); - - // Update the state with the recipe we got from the server. setState(() { - _errorMessage = null; _resultMessage = '${result.author} on ${result.date}:\n${result.text}'; _loading = false; }); - } catch (e) { - // If something goes wrong, set an error message. - setState(() { - _errorMessage = '$e'; - _resultMessage = null; - _loading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: TextField( - controller: _textEditingController, - decoration: const InputDecoration( - hintText: 'Enter your ingredients', - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: ElevatedButton( - onPressed: _loading ? null : _callGenerateRecipe, - child: _loading - ? const Text('Loading...') - : const Text('Generate Recipe'), - ), - ), - Expanded( - child: SingleChildScrollView( - child: ResultDisplay( - resultMessage: _resultMessage, - errorMessage: _errorMessage, - ), - ), - ), - ], - ), - ), - ); - } -} - -class SignInScreen extends StatelessWidget { - const SignInScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Center( - child: SignInWidget( - client: client, - onAuthenticated: () {}, - ), - ); - } -} - -class ConnectedScreen extends StatefulWidget { - const ConnectedScreen({super.key}); - - @override - State createState() => _ConnectedScreenState(); -} - -class _ConnectedScreenState extends State { - /// Holds the last result or null if no result exists yet. - String? _resultMessage; - - /// Holds the last error message that we've received from the server or null - /// if no error exists yet. - String? _errorMessage; - - final _textEditingController = TextEditingController(); - - /// Calls the `hello` method of the `greeting` endpoint. Will set either the - /// `_resultMessage` or `_errorMessage` field, depending on if the call - /// is successful. - void _callHello() async { - try { - final result = await client.greeting.hello(_textEditingController.text); - setState(() { - _errorMessage = null; - _resultMessage = result.message; - }); - } catch (e) { - setState(() { - _errorMessage = '$e'; - }); - } - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - const Text('You are connected'), - ElevatedButton( - onPressed: () async { - await client.auth.signOutDevice(); - }, - child: const Text('Sign out'), - ), - const SizedBox(height: 32), - TextField( - controller: _textEditingController, - decoration: const InputDecoration(hintText: 'Enter your name'), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _callHello, - child: const Text('Send to Server'), - ), - const SizedBox(height: 16), - ResultDisplay( - resultMessage: _resultMessage, - errorMessage: _errorMessage, - ), - ], - ), - ); - } -} - -/// ResultDisplays shows the result of the call. Either the returned result -/// from the `example.greeting` endpoint method or an error message. -class ResultDisplay extends StatelessWidget { - final String? resultMessage; - final String? errorMessage; - - const ResultDisplay({super.key, this.resultMessage, this.errorMessage}); - - @override - Widget build(BuildContext context) { - String text; - Color backgroundColor; - if (errorMessage != null) { - backgroundColor = Colors.red[300]!; - text = errorMessage!; - } else if (resultMessage != null) { - backgroundColor = Colors.green[300]!; - text = resultMessage!; - } else { - backgroundColor = Colors.grey[300]!; - text = 'No server response yet.'; - } - - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 50), - child: Container( - color: backgroundColor, - child: Center(child: Text(text)), - ), - ); - } -} ``` - -

-
-## Run the app +Save. Because the model and the endpoint's return type changed, the generated client changed too, so hot reload isn't enough. -First, start the server: +In the `serverpod start` terminal: -```bash -$ cd magic_recipe_server -$ docker compose up -d -$ dart bin/main.dart -``` - -Then, start the Flutter app: +- Press **R** to hot restart. -```bash -$ cd magic_recipe_flutter -$ flutter run -d chrome -``` +Enter some ingredients and tap **Generate Recipe**. The result now shows the author and date alongside the recipe: -This will start the Flutter app in your browser. It should look something like this: ![Flutter Recipe App](/img/getting-started/flutter-web-ingredients.png) -Click the button to get a new recipe. The app will call the endpoint on the server and display the result in the app. - ## Next steps -On the Flutter side, there are quite a few things that could be improved, like a nicer display of the result, e.g., using a markdown renderer. - -In the next section, you will learn how to use the database to store your favorite recipes and display them in your app. +Your endpoint now returns structured data. Next, you'll store recipes in the database so they persist between sessions, and list them in the app. diff --git a/docs/05-build-your-first-app/03-working-with-the-database.md b/docs/05-build-your-first-app/03-working-with-the-database.md index 5c467102..eec83282 100644 --- a/docs/05-build-your-first-app/03-working-with-the-database.md +++ b/docs/05-build-your-first-app/03-working-with-the-database.md @@ -1,21 +1,26 @@ --- -sidebar_label: Manage the database +title: Manage the database sidebar_class_name: sidebar-icon-get-started-step-3 slug: /get-started/working-with-the-database +description: Store your Serverpod recipes in the database with typed methods and migrations, so they persist between sessions and can be listed in your app. --- + + # Manage the database -In this section, we will build upon the models we created in the previous section and add a database to store the recipes that users create in the app. This will allow our application to persist data between sessions. +Right now your recipes disappear when the Flutter app reloads. Here you'll store them in the database so they persist, and list previously generated recipes in the app. Serverpod maps your model to a table and gives you a type-safe API to read and write rows, without writing any SQL. + +Keep `serverpod start` running from the previous page. -## Object relation mapping +## Map the model to a table -Any Serverpod model can be mapped to the database through Serverpod's object relation mapping (ORM). To enable database storage for our recipe model, simply add the `table` keyword to the `Recipe` model in our `recipe.spy.yaml` file. This will map the model to a new table in the database called `recipes`. +Add the `table` keyword to your `Recipe` model in `recipe.spy.yaml`. This maps the model to a database table called `recipes`: - ```yaml ### Our AI generated Recipe class: Recipe +### The database table that stores recipes table: recipes fields: ### The author of the recipe @@ -24,43 +29,30 @@ fields: text: String ### The date the recipe was created date: DateTime - ### The ingredients the user has passed in + ### The ingredients the user passed in ingredients: String ``` - + +Save the file. The regenerated `Recipe` class now exposes database methods through `Recipe.db`. :::info -Check out the reference for [database models](../06-concepts/02-models/01-models.md#keywords-1) for an overview of all available keywords. +See the [database models](../06-concepts/02-models/01-models.md#keywords-1) reference for all the keywords you can use in a table. ::: -## Migrations +## Create and apply the migration -Database migrations in Serverpod provide a way to safely evolve your database schema over time. When you make changes to your models that affect the database structure, you need to create a migration. A migration consists of SQL queries that update the database schema to match your model changes. +Changing the schema requires a [migration](../06-concepts/06-database/11-migrations.md): a set of SQL steps that bring the database up to date with your models. The `serverpod start` terminal has shortcuts for this, listed along the bottom. With that terminal focused: -To create a migration, follow these two steps in order: +![serverpod start tui](/img/getting-started/tui-logs.png) -1. Run `serverpod generate` to update the generated code based on your model changes. -2. Run `serverpod create-migration` to create the necessary database migration. +- Press **M** to create the migration from your model change. +- Press **A** to apply it, which creates the `recipes` table in your database. -```bash -$ cd magic_recipe_server -$ serverpod generate -$ serverpod create-migration -``` +## Save recipes to the database -Each time you run `serverpod create-migration`, a new migration file will be created in your _migrations_ folder. These step-by-step migrations provide a history of your database changes and allow you to roll back changes if needed. +Now that `Recipe` is a table, you can write rows. In `recipe_endpoint.dart`, save the generated recipe before returning it. Replace the `return Recipe(...)` you added earlier with: -## Writing to the database - -Now that we've added the `table` keyword to our `Recipe` model, it becomes a `TableRow` type, giving us access to database operations. Serverpod automatically generates database bindings that we can access through the static `db` field of the `Recipe` class. Let's use the `insertRow` method to save new recipes to the database: - - ```dart -// ... -class RecipeEndpoint extends Endpoint { - /// Pass in a string containing the ingredients and get a recipe back. - Future generateRecipe(Session session, String ingredients) async { -// ... final recipe = Recipe( author: 'Gemini', text: responseText, @@ -68,210 +60,66 @@ class RecipeEndpoint extends Endpoint { ingredients: ingredients, ); - // Save the recipe to the database, the returned recipe has the id set. - final recipeWithId = await Recipe.db.insertRow(session, recipe); - - return recipeWithId; - } - + return Recipe.db.insertRow(session, recipe); ``` - -## Reading from the database +`insertRow` returns the saved row with its `id` populated by the database. -Next, let's add a new method to the `RecipeEndpoint` class that will return all the recipes that we have created and saved to the database. +## List past recipes -To make sure that we get them in the correct order, we sort them by the date they were created. +Add a second method to the endpoint that returns every saved recipe, newest first: - ```dart -// ... -class RecipeEndpoint extends Endpoint { - /// Pass in a string containing the ingredients and get a recipe back. - Future generateRecipe(Session session, String ingredients) async { -// ... - } - - /// This method returns all the generated recipes from the database. + /// Returns all recipes saved in the database, most recent first. Future> getRecipes(Session session) async { - // Get all the recipes from the database, sorted by date. - return Recipe.db.find( - session, - orderBy: (t) => t.date.desc(), - ); + return Recipe.db.find(session, orderBy: (t) => t.date.desc()); } -} ``` - - -
- -Click to see the full code -

- - -```dart -import 'dart:async'; - -import 'package:dartantic_ai/dartantic_ai.dart'; -import 'package:magic_recipe_server/src/generated/protocol.dart'; -import 'package:serverpod/serverpod.dart'; - -/// This is the endpoint that will be used to generate a recipe using the -/// Google Gemini API. It extends the Endpoint class and implements the -/// generateRecipe method. -class RecipeEndpoint extends Endpoint { - /// Pass in a string containing the ingredients and get a recipe back. - Future generateRecipe(Session session, String ingredients) async { - // Serverpod automatically loads your passwords.yaml file and makes the passwords available - // in the session.passwords map. - final geminiApiKey = session.passwords['geminiApiKey']; - if (geminiApiKey == null) { - throw Exception('Gemini API key not found'); - } - - // Configure the Dartantic AI agent for Gemini before sending the prompt. - final agent = Agent.forProvider( - GoogleProvider(apiKey: geminiApiKey), - chatModelName: 'gemini-2.5-flash-lite', - ); - - // A prompt to generate a recipe, the user will provide a free text input with the ingredients. - final prompt = - 'Generate a recipe using the following ingredients: $ingredients, always put the title ' - 'of the recipe in the first line, and then the instructions. The recipe should be easy ' - 'to follow and include all necessary steps. Please provide a detailed recipe.'; - - final response = await agent.send(prompt); - final responseText = response.output; - - // Check if the response is empty. - if (responseText.isEmpty) { - throw Exception('No response from Gemini API'); - } - - final recipe = Recipe( - author: 'Gemini', - text: responseText, - date: DateTime.now(), - ingredients: ingredients, - ); - - // Save the recipe to the database, the returned recipe has the id set. - final recipeWithId = await Recipe.db.insertRow(session, recipe); - - return recipeWithId; - } - - /// This method returns all the generated recipes from the database. - Future> getRecipes(Session session) async { - // Get all the recipes from the database, sorted by date. - return Recipe.db.find( - session, - orderBy: (t) => t.date.desc(), - ); - } -} -``` - - -

-
:::info -The when adding a `table` to the model class definition, the model will now give you access to the database. In this case we find the database methods under `Recipe.db`. - -The `insertRow` method is used to insert a new row into the database. The `find` method is used to query the database and get all the rows of a specific type. See [CRUD](../06-concepts/06-database/05-crud.md) and [relation queries](../06-concepts/06-database/07-relation-queries.md) for more information. +`insertRow` and `find` are Serverpod's typed database methods. See [CRUD](../06-concepts/06-database/05-crud.md) for the full set of operations. ::: -## Generate the code - -Like before, when you change something that has an effect on your client code, you need to run `serverpod generate`. We don't need to run `serverpod create-migrations` again because we already created a migration in the previous step and haven't done any changes that affect the database. - -```bash -$ cd magic_recipe_server -$ serverpod generate -``` - -## Call the endpoint from the app - -Now that we have updated the endpoint, we can call it from the app. We do this in the `magic_recipe_flutter/lib/main.dart` file. We will call the `getRecipes` method when the app starts and store the result in a list of `Recipe` objects. We will also update the UI to show the list of recipes. - -![Final result](/img/getting-started/final-result.png) +## Show the saved recipes in your app -If you want to see what changed, we suggest to creating a git commit now and then replacing the code in the `main.dart` file. +Update `recipe_screen.dart` to load past recipes when it opens and list them next to the generator. Replace the file with: - ```dart -import 'package:magic_recipe_client/magic_recipe_client.dart'; import 'package:flutter/material.dart'; -import 'package:serverpod_flutter/serverpod_flutter.dart'; - -/// Sets up a global client object that can be used to talk to the server from -/// anywhere in our app. The client is generated from your server code -/// and is set up to connect to a Serverpod running on a local server on -/// the default port. You will need to modify this to connect to staging or -/// production servers. -/// In a larger app, you may want to use the dependency injection of your choice -/// instead of using a global client object. This is just a simple example. -late final Client client; - -late String serverUrl; - -void main() { - // When you are running the app on a physical device, you need to set the - // server URL to the IP address of your computer. You can find the IP - // address by running `ipconfig` on Windows or `ifconfig` on Mac/Linux. - // You can set the variable when running or building your app like this: - // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` - const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); - final serverUrl = - serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv; - - client = Client(serverUrl) - ..connectivityMonitor = FlutterConnectivityMonitor(); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Serverpod Demo', - theme: ThemeData( - primarySwatch: Colors.blue, - ), - home: const MyHomePage(title: 'Serverpod Example'), - ); - } -} +import 'package:magic_recipe_client/magic_recipe_client.dart'; -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +import '../main.dart'; +import 'greetings_screen.dart'; - final String title; +class RecipeScreen extends StatefulWidget { + const RecipeScreen({super.key}); @override - MyHomePageState createState() => MyHomePageState(); + State createState() => _RecipeScreenState(); } -class MyHomePageState extends State { - /// Holds the last result or null if no result exists yet. +class _RecipeScreenState extends State { + /// The recipe currently shown, or null if there's none yet. Recipe? _recipe; + /// Recipes loaded from the database, most recent first. List _recipeHistory = []; - /// Holds the last error message that we've received from the server or null - /// if no error exists yet. + /// The last error message, or null if there's no error. String? _errorMessage; final _textEditingController = TextEditingController(); bool _loading = false; + @override + void initState() { + super.initState(); + client.recipe.getRecipes().then((recipes) { + setState(() => _recipeHistory = recipes); + }); + } + void _callGenerateRecipe() async { try { setState(() { @@ -279,186 +127,93 @@ class MyHomePageState extends State { _recipe = null; _loading = true; }); - final result = - await client.recipe.generateRecipe(_textEditingController.text); + + final result = await client.recipe.generateRecipe( + _textEditingController.text, + ); + setState(() { - _errorMessage = null; _recipe = result; + _recipeHistory = [result, ..._recipeHistory]; _loading = false; - _recipeHistory.insert(0, result); }); } catch (e) { setState(() { _errorMessage = '$e'; - _recipe = null; _loading = false; }); } } - @override - void initState() { - super.initState(); - // Get the favourite recipes from the database - client.recipe.getRecipes().then((favouriteRecipes) { - setState(() { - _recipeHistory = favouriteRecipes; - }); - }); - } - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - ), - body: Row( - children: [ - Expanded( - child: DecoratedBox( - decoration: BoxDecoration(color: Colors.grey[300]), - child: ListView.builder( - itemCount: _recipeHistory.length, - itemBuilder: (context, index) { - final recipe = _recipeHistory[index]; - return ListTile( - title: Text( - recipe.text.substring(0, recipe.text.indexOf('\n'))), - subtitle: Text('${recipe.author} - ${recipe.date}'), - onTap: () { - // Show the recipe in the text field - _textEditingController.text = recipe.ingredients; - setState(() { - _recipe = recipe; - }); - }, - ); - }, - ), - ), + return Row( + children: [ + Expanded( + child: ListView.builder( + itemCount: _recipeHistory.length, + itemBuilder: (context, index) { + final recipe = _recipeHistory[index]; + return ListTile( + title: Text(recipe.text.split('\n').first), + subtitle: Text('${recipe.author} - ${recipe.date}'), + onTap: () => setState(() => _recipe = recipe), + ); + }, ), - Expanded( - flex: 3, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: TextField( - controller: _textEditingController, - decoration: const InputDecoration( - hintText: 'Enter your ingredients', - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: ElevatedButton( - onPressed: _loading ? null : _callGenerateRecipe, - child: _loading - ? const Text('Loading...') - : const Text('Send to Server'), - ), + ), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _textEditingController, + decoration: const InputDecoration( + hintText: 'Enter your ingredients', ), - Expanded( - child: SingleChildScrollView( - child: - // Change the ResultDisplay to use the Recipe object - ResultDisplay( - resultMessage: _recipe != null - ? '${_recipe?.author} on ${_recipe?.date}:\n' - '${_recipe?.text}' - : null, - errorMessage: _errorMessage, - ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loading ? null : _callGenerateRecipe, + child: _loading + ? const Text('Loading...') + : const Text('Generate Recipe'), + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: ResultDisplay( + resultMessage: _recipe != null + ? '${_recipe!.author} on ${_recipe!.date}:\n${_recipe!.text}' + : null, + errorMessage: _errorMessage, ), ), - ], - ), + ), + ], ), ), - ], - ), - ); - } -} - -/// ResultDisplays shows the result of the call. Either the returned result from -/// the `example.greeting` endpoint method or an error message. -class ResultDisplay extends StatelessWidget { - final String? resultMessage; - final String? errorMessage; - - const ResultDisplay({ - super.key, - this.resultMessage, - this.errorMessage, - }); - - @override - Widget build(BuildContext context) { - String text; - Color backgroundColor; - if (errorMessage != null) { - backgroundColor = Colors.red[300]!; - text = errorMessage!; - } else if (resultMessage != null) { - backgroundColor = Colors.green[300]!; - text = resultMessage!; - } else { - backgroundColor = Colors.grey[300]!; - text = 'No server response yet.'; - } - - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 50), - child: Container( - color: backgroundColor, - child: Center( - child: Text(text), ), - ), + ], ); } } ``` - - -## Run the app - -To run the application with database support, follow these steps in order: - -First, start the database and apply migrations: - -```bash -$ cd magic_recipe_server -$ docker compose up -d # Start the database container -$ dart bin/main.dart --apply-migrations # Apply any pending migrations -``` -:::tip -The `--apply-migrations` flag is safe to use during development - if no migrations are pending, it will simply be ignored. For production environments, you may want more controlled migration management. -::: +The new `import 'package:magic_recipe_client/magic_recipe_client.dart';` line brings in the `Recipe` class Serverpod generated from your model. It's the same class the server uses, so when you read `recipe.author`, `recipe.text`, or `recipe.date` in the app, the field names and types are guaranteed to match the server. -Next, launch the Flutter app: +You added a new endpoint method (`getRecipes`), so the generated client changed. -```bash -$ cd magic_recipe_flutter -$ flutter run -d chrome -``` +In the `serverpod start` terminal: -## Summary +- Press **R** to hot restart. -You've now learned the fundamentals of Serverpod: +Generate a few recipes, then reload the page. They're still there, loaded from the database: -- Creating and using endpoints with custom data models. -- Storing data persistently in a database. -- Using the generated client code in your Flutter app. +![Final result](/img/getting-started/final-result.png) -We're excited to see what you'll build with Flutter and Serverpod! If you need help, don't hesitate to ask questions in our [community on Github](https://github.com/serverpod/serverpod/discussions). Both the Serverpod team and community members are active and ready to help. To connect with other Serverpod users we also have a [Discord community](https://serverpod.dev/discord). +## Next steps -:::tip -Database operations are a broad topic, and Serverpod's ORM offers many powerful features. To learn more about advanced database operations, check out the [Database](../06-concepts/06-database/01-connection.md) section. -::: +Your app now persists data. With the recipe generator working end to end, the last step is to put it online: next, you'll deploy it to Serverpod Cloud. diff --git a/docs/05-build-your-first-app/04-deployment.md b/docs/05-build-your-first-app/04-deployment.md index 08d89447..794ec4c1 100644 --- a/docs/05-build-your-first-app/04-deployment.md +++ b/docs/05-build-your-first-app/04-deployment.md @@ -1,39 +1,57 @@ --- -sidebar_label: Deploy your server +title: Deploy your app sidebar_class_name: sidebar-icon-get-started-step-4 slug: /get-started/deployment +description: Deploy your Serverpod recipe app to Serverpod Cloud with the scloud CLI, then explore other hosting options. --- -# Deploy your server + -## Server requirements +# Deploy your app -Serverpod is written in Dart and compiles to native code, allowing it to run on any platform supported by the [Dart tooling](https://dart.dev/get-dart#system-requirements). +Your recipe app runs locally. The last step is to put it online. The recommended path is [Serverpod Cloud](/cloud), which hosts your server and database with zero configuration. -Many users prefer to deploy Serverpod using Docker. The project includes a basic Dockerfile that you can use to build a Docker image, which can then be run on any Docker-compatible platform. +## Deploy to Serverpod Cloud -For non-Docker deployments, you'll need to [compile the Dart code](https://dart.dev/tools/dart-compile) and manually copy your assets and configuration files to the server. This manual step is necessary since [asset bundling is not yet supported by Dart](https://github.com/dart-lang/sdk/issues/55195). +Install the Serverpod Cloud CLI: -## Server configuration +```bash +$ dart pub global activate serverpod_cloud_cli +``` -By default Serverpod is active on three ports: +From your project's root folder, launch the app. This creates a Cloud project, provisions a managed Postgres database (separate from the embedded one `serverpod start` runs locally), and deploys your server along with the web build of your app: -- **8080**: The main port for the server - this is where the generated client will connect to. -- **8081**: The port for connecting with the [Serverpod Insights](../tools/insights) tooling. You may want to restrict which IP addresses can connect to this port. -- **8082**: The built in webserver is running on this port. +```bash +$ scloud launch +``` -You will also need to configure the database connection in the `config/production.yaml` file and **securely** provide the `config/passwords.yaml` file to the server. +The first upload includes your Flutter web build and can exceed the default timeout on a slower connection. If the upload times out, retry with a higher limit (in seconds), for example, `scloud launch --timeout 600`. -## Health checks +Your Gemini API key lives in `passwords.yaml`, which stays on your machine and is never deployed. Set it as a secret in Cloud so the deployed server can call Gemini: -The server exposes a health check on the root endpoint `/` on port **8080**. Load balancers and monitoring systems can use this endpoint to verify that your server is running and healthy. The endpoint returns a basic health status response. +```bash +$ scloud password set geminiApiKey +``` -## Deploying with Serverpod Cloud +Whenever you make changes later, redeploy with: -Serverpod Cloud is a managed service that allows you to deploy your Serverpod applications without having to worry about the underlying infrastructure. +```bash +$ scloud deploy +``` -Serverpod Cloud is currently in private beta. Request access by [filling out this form](https://docs.google.com/forms/d/e/1FAIpQLSfBteB7hoLJ2xPgs0CXj9RpLt2gogvJZSpEv2ye8ziWuXfGFA/viewform). Once you have access, you can deploy your Serverpod applications to the cloud in just a few minutes and with zero configuration. +See the [Serverpod Cloud documentation](/cloud) for the full walkthrough, including custom domains, logs, and your free trial. ## Other deployment options -Check out [Custom hosting](../08-deployments/custom-hosting/01-choosing-a-strategy.md) for more information on how to deploy your Serverpod application to other platforms. +Prefer to host the server yourself? See [Custom hosting](../08-deployments/custom-hosting/01-choosing-a-strategy.md) for running on a server cluster, a serverless platform, or your own machine. + +## What you've built + +You've built and deployed a full-stack app with Flutter and Serverpod: + +- A custom endpoint that calls an external API from the server. +- A type-safe data model shared between the server and the Flutter app. +- Persistent storage with the database. +- A Flutter app that talks to your server through the generated client. + +We're excited to see what you'll build next. If you need help, join the [Discord community](https://serverpod.dev/discord) or ask in our [community on GitHub](https://github.com/serverpod/serverpod/discussions). To go deeper into any topic, browse the [Concepts](../06-concepts/01-working-with-endpoints/01-working-with-endpoints.md) section. diff --git a/static/img/getting-started/endpoint-chrome-result.png b/static/img/getting-started/endpoint-chrome-result.png index 489de5b7..71597faa 100644 Binary files a/static/img/getting-started/endpoint-chrome-result.png and b/static/img/getting-started/endpoint-chrome-result.png differ diff --git a/static/img/getting-started/final-result.png b/static/img/getting-started/final-result.png index d5a5f098..d72935bf 100644 Binary files a/static/img/getting-started/final-result.png and b/static/img/getting-started/final-result.png differ diff --git a/static/img/getting-started/flutter-web-ingredients.png b/static/img/getting-started/flutter-web-ingredients.png index fff63016..997e148a 100644 Binary files a/static/img/getting-started/flutter-web-ingredients.png and b/static/img/getting-started/flutter-web-ingredients.png differ diff --git a/static/img/getting-started/tui-logs.png b/static/img/getting-started/tui-logs.png new file mode 100644 index 00000000..9e5f94df Binary files /dev/null and b/static/img/getting-started/tui-logs.png differ