Skip to content

Create a project

You’ll create a small todo_api server with one controller, generate its routing code and call it with curl. At the end you have the standard gisila project layout to build on.

  • Dart SDK 3.3 or newer (dart --version).
  • curl, or any HTTP client.
Terminal window
dart create -t console todo_api
cd todo_api

Edit pubspec.yaml:

name: todo_api
publish_to: none
environment:
sdk: ^3.3.0
dependencies:
gisila: ^0.4.1
dev_dependencies:
build_runner: ">=2.5.0 <3.0.0"
lints: ">=3.0.0 <7.0.0"
test: ^1.24.0

If you work from a local checkout of the gisila repository, use gisila: {path: ../gisila} instead. Then:

Terminal window
dart pub get

package:gisila/gisila.dart re-exports what you need from shelf and shelf_router, so you don’t add them yourself.

Create lib/controllers/todos_controller.dart:

import 'package:gisila/gisila.dart';
part 'todos_controller.g.dart';
@Controller('/todos')
class TodosController {
final Map<String, String> _todos = {};
@Get('/')
Future<List<Map<String, Object?>>> list() async => [
for (final e in _todos.entries) {'id': e.key, 'text': e.value},
];
@Get('/{id}')
Future<Map<String, Object?>> show(String id) async {
final text = _todos[id];
if (text == null) throw NotFound('Todo "$id" not found');
return {'id': id, 'text': text};
}
@Post('/')
Future<Map<String, Object?>> create(Map<String, dynamic> body) async {
final text = body['text'];
if (text is! String || text.isEmpty) throw BadRequest('"text" is required');
final id = '${_todos.length + 1}';
_todos[id] = text;
return {'id': id, 'text': text};
}
}

The part line is required: it is where the generated code goes. id is bound to the {id} placeholder, body is the decoded JSON body, and @Post answers 201 by default.

Terminal window
dart run build_runner build --delete-conflicting-outputs

This writes lib/controllers/todos_controller.g.dart with a TodosControllerGisilaWeb extension that adds attachToApp. While developing, keep dart run build_runner watch --delete-conflicting-outputs running in a second terminal.

Replace bin/todo_api.dart:

import 'package:gisila/gisila.dart';
import 'package:todo_api/controllers/todos_controller.dart';
Future<void> main() async {
final app = GisilaApp(
config: AppConfig(
logLevel: LogLevel.info,
maxRequestBodyBytes: 64 * 1024,
healthCheckPath: '/healthz',
),
);
app.registerController(attacher: TodosController().attachToApp);
await app.start(port: 8080, stopOnSignals: true);
}
Terminal window
dart run bin/todo_api.dart
# 2026-10-31T09:00:00.000Z [INFO ] gisila listening on http://0.0.0.0:8080 controllers=1 middleware=0

As the app grows, use this layout:

todo_api/
├── bin/server.dart # main(): config, registration, start()
├── lib/
│ ├── controllers/ # @Controller classes (+ generated .g.dart)
│ ├── forms/ # Form subclasses
│ ├── services/ # Service subclasses
│ ├── models/ # DTOs with toJson / fromJson
│ └── config.dart # builds AppConfig from the environment
└── test/ # GisilaTestClient tests
Terminal window
curl -i -X POST localhost:8080/todos -H 'content-type: application/json' -d '{"text":"buy milk"}'
# HTTP/1.1 201 Created
# {"id":"1","text":"buy milk"}
curl localhost:8080/todos
# [{"id":"1","text":"buy milk"}]
curl -i localhost:8080/todos/9
# HTTP/1.1 404 Not Found
# {"error":{"status":404,"message":"Todo \"9\" not found","code":"not_found"}}
curl localhost:8080/healthz
# {"status":"ok"}

Every response carries an X-Request-Id header and the default security headers.