Skip to content

Commit 24fa8d9

Browse files
docs: improve quick_start
1 parent 3766862 commit 24fa8d9

11 files changed

Lines changed: 295 additions & 35 deletions

File tree

.website/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export default defineConfig({
150150
text: 'Overview',
151151
base: '/',
152152
items: [
153+
{ text: 'Quick Start', link: 'quick_start' },
153154
{ text: 'Modules', link: 'modules' },
154155
{ text: 'Controllers', link: 'controllers' },
155156
{ text: 'Routes', link: 'routes' },

.website/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"license": "ISC",
1515
"devDependencies": {
1616
"baseline-browser-mapping": "^2.8.30",
17-
"vitepress": "2.0.0-alpha.15"
17+
"vitepress": "2.0.0-alpha.16"
1818
},
1919
"dependencies": {
2020
"@avesbox/canary": "^0.1.0",

.website/quick_start.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,175 @@ serinus run --dev
6868
```
6969

7070
This will start the server on `http://localhost:3000` in development mode allowing you to leverage on an hot-restarter to automatically restart the server when a file is changed.
71+
72+
## Let's complete it
73+
74+
So now we have the application running but we should start adding some features to see how things really work.
75+
76+
## Update the Todo model
77+
78+
The `Todo` class is already augmented with the `JsonObject` mixin, meaning that this object can be converted to its json representation by the framework. But we also need to create it directly from the body although not the `Todo` class itself so let's create a `TodoDto` class.
79+
80+
```dart
81+
class Todo with JsonObject{
82+
final String title;
83+
bool isDone;
84+
85+
Todo({
86+
required this.title,
87+
this.isDone = false,
88+
});
89+
90+
@override
91+
Map<String, dynamic> toJson() {
92+
return {
93+
'title': title,
94+
'isDone': isDone,
95+
};
96+
}
97+
}
98+
99+
class TodoDto {
100+
101+
final String title;
102+
103+
const TodoDto({
104+
required this.title,
105+
});
106+
107+
factory TodoDto.fromJson(Map<String, dynamic> json) {
108+
return TodoDto(
109+
title: json['title'],
110+
);
111+
}
112+
}
113+
114+
```
115+
116+
## Generate the models
117+
118+
As you can see the `TodoDto` class has a `fromJson` factory constructor that will be used by the cli to generate the [ModelProvider](/techniques/model_provider.html). So let's do exactly that.
119+
120+
Let's execute this command:
121+
122+
```bash
123+
serinus generate models
124+
```
125+
126+
And now we have a new file `model_provider` in the root of the `lib` folder.
127+
128+
```dart
129+
import 'package:serinus/serinus.dart';
130+
131+
import 'todo.dart';
132+
133+
/// The [MyProjectModelProvider] is used to provide models for the Serinus application.
134+
/// It contains mappings for serializing and deserializing models to and from JSON.
135+
class MyProjectModelProvider extends ModelProvider {
136+
@override
137+
Map<String, Function> get toJsonModels {
138+
return {"Todo": (model) => (model as Todo).toJson()};
139+
}
140+
141+
@override
142+
Map<String, Function> get fromJsonModels {
143+
return {"TodoDto": (json) => TodoDto.fromJson(json)};
144+
}
145+
}
146+
```
147+
148+
Let's add it to the application.
149+
150+
```dart
151+
import 'package:serinus/serinus.dart';
152+
153+
import 'app_module.dart';
154+
import 'model_provider.dart';
155+
156+
/// The bootstrap function is the entry point of the application.
157+
/// It will be called by the `entrypoint` file in the bin directory.
158+
///
159+
/// This function creates a Serinus application using the [AppModule]
160+
/// as the root module, and starts the server on host '0.0.0.0' and port 3000.
161+
Future<void> bootstrap() async {
162+
final app = await serinus.createApplication(
163+
entrypoint: AppModule(),
164+
host: '0.0.0.0',
165+
port: 3000,
166+
modelProvider: MyProjectModelProvider()
167+
);
168+
await app.serve();
169+
}
170+
```
171+
172+
## Use the model as the body in the TodoController
173+
174+
First of all let's create a Pipe to validate the body.
175+
176+
```dart
177+
import 'package:serinus/serinus.dart';
178+
179+
import 'todo.dart';
180+
181+
class TodoPipe extends Pipe {
182+
@override
183+
Future<void> transform(ExecutionContext context) async {
184+
if (context.argumentsHost is! HttpArgumentsHost) {
185+
return;
186+
}
187+
final reqContext = context.switchToHttp();
188+
final body = reqContext.body;
189+
if (body is TodoDto) {
190+
if (body.title.isEmpty) {
191+
throw BadRequestException('Title cannot be empty');
192+
}
193+
return;
194+
}
195+
throw BadRequestException('The body is not correct!');
196+
}
197+
}
198+
```
199+
200+
Then let's bind the pipe to the controller and specify the DTO to the route that will create the `Todo`.
201+
202+
```dart
203+
import 'package:serinus/serinus.dart';
204+
205+
import 'app_provider.dart';
206+
import 'todo.dart';
207+
import 'todo_pipe.dart';
208+
209+
class AppController extends Controller {
210+
211+
AppController(): super('/'){
212+
/// ...
213+
on<Todo, TodoDto>(
214+
Route.post(
215+
'/',
216+
pipes: {
217+
TodoPipe()
218+
}
219+
),
220+
_createTodo
221+
);
222+
/// ...
223+
}
224+
225+
///...
226+
227+
Future<Todo> _createTodo(RequestContext<TodoDto> context) async {
228+
context.use<AppProvider>().addTodo(context.body.title);
229+
return context.use<AppProvider>().todos.last;
230+
}
231+
232+
///...
233+
234+
235+
}
236+
```
237+
238+
Now when you do a `POST` request to `/` everything will be safe and sound.
239+
240+
## Conclusion
241+
242+
We've created a REST Api that automatically convert and validates the body of a request without code generation and **magic** stuff like that.

packages/serinus/lib/src/contexts/request_context.dart

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -387,23 +387,27 @@ class _BodyConverter {
387387
}
388388
}
389389
if (modelProvider != null) {
390-
if (value is FormData) {
391-
final map = {...value.fields, ...value.files};
392-
return modelProvider!.from(
393-
'$targetType',
394-
Map<String, dynamic>.from(map),
395-
);
396-
}
397-
if (value is Map) {
398-
final mapped = value.map((key, val) => MapEntry('$key', val));
399-
final result = modelProvider!.from(
400-
'$targetType',
401-
Map<String, dynamic>.from(mapped),
402-
);
403-
if (allowsNull && result == null) {
404-
return null;
390+
try {
391+
if (value is FormData) {
392+
final map = {...value.fields, ...value.files};
393+
return modelProvider!.from(
394+
'$targetType',
395+
Map<String, dynamic>.from(map),
396+
);
397+
}
398+
if (value is Map) {
399+
final mapped = value.map((key, val) => MapEntry('$key', val));
400+
final result = modelProvider!.from(
401+
'$targetType',
402+
Map<String, dynamic>.from(mapped),
403+
);
404+
if (allowsNull && result == null) {
405+
return null;
406+
}
407+
return result;
405408
}
406-
return result;
409+
} catch (e) {
410+
throw BadRequestException('An error occured when parsing the object');
407411
}
408412
if (value is List) {
409413
throw BadRequestException('The element is not of the expected type');

packages/serinus/lib/src/core/middlewares/middleware_registry.dart

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -394,25 +394,34 @@ class MiddlewareRegistry extends Provider with OnApplicationBootstrap {
394394
}
395395

396396
// Check if resolver path is parametric and can match target
397-
final parametricRegex = RegExp(r'<[^>]+>');
398-
399-
if (resolverPath.contains('<')) {
397+
if (_hasDynamicSegments(resolverPath)) {
400398
// Convert parametric path to regex pattern
401-
final pattern = resolverPath.replaceAll(parametricRegex, r'([^/]+)');
399+
final pattern = _toParametricPattern(resolverPath);
402400
final regex = RegExp('^$pattern\$');
403401
return regex.hasMatch(targetPath);
404402
}
405403

406404
// Check if target path is parametric and can match resolver
407-
if (targetPath.contains('<')) {
408-
final pattern = targetPath.replaceAll(parametricRegex, r'([^/]+)');
405+
if (_hasDynamicSegments(targetPath)) {
406+
final pattern = _toParametricPattern(targetPath);
409407
final regex = RegExp('^$pattern\$');
410408
return regex.hasMatch(resolverPath);
411409
}
412410

413411
return false;
414412
}
415413

414+
bool _hasDynamicSegments(String path) {
415+
return path.contains('<') || path.contains(RegExp(r':[A-Za-z_]\w*\??'));
416+
}
417+
418+
String _toParametricPattern(String path) {
419+
var pattern = path;
420+
pattern = pattern.replaceAll(RegExp(r'<[^>]+>\??'), r'([^/]+)');
421+
pattern = pattern.replaceAll(RegExp(r':[A-Za-z_]\w*\??'), r'([^/]+)');
422+
return pattern;
423+
}
424+
416425
/// Checks if versions are compatible
417426
bool _versionsMatch(
418427
List<VersioningOptions>? targetVersions,

packages/serinus/lib/src/routes/routes_explorer.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,6 @@ final class RoutesExplorer {
3535
),
3636
};
3737
for (var controller in controllers.entries) {
38-
if (controller.value.path.contains(RegExp(r'([\/]{2,})*([\:][\w+]+)'))) {
39-
throw Exception('Invalid controller path: ${controller.value.path}');
40-
}
4138
logger.info('${controller.key.runtimeType} {${controller.value.path}}');
4239
final versioningOptions = _container.config.versioningOptions;
4340
final globalVersioningEnabled =

packages/serinus/lib/src/routes/routes_resolver.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ class RoutesResolver {
5555
entry.controller: ControllerSpec(entry.controller.path, entry.module),
5656
};
5757
for (var controller in mappedControllers.entries) {
58-
if (controller.value.path.contains(RegExp(r'([\/]{2,})*([\:][\w+]+)'))) {
59-
throw Exception('Invalid controller path: ${controller.value.path}');
60-
}
6158
_logger.info('${controller.key.runtimeType} {${controller.value.path}}');
6259
_explorer.explore(
6360
controller,

packages/serinus/test/core/middlewares_test.dart

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ class TestModule extends Module {
9494
.apply([TestModuleMiddleware(), shelfMiddleware, shelfAltMiddleware])
9595
.forControllers([TestController])
9696
.exclude([RouteInfo('/request-event')]);
97+
consumer.apply([DynamicParamMiddleware()]).forControllers([
98+
DynamicController,
99+
]);
97100
consumer.apply([r]).forRoutes([RouteInfo('/request-event')]);
98101
}
99102
}
@@ -109,11 +112,33 @@ class TestModuleMiddleware extends Middleware {
109112
}
110113
}
111114

115+
class DynamicParamMiddleware extends Middleware {
116+
@override
117+
Future<void> use(ExecutionContext context, NextFunction next) async {
118+
final argumentsHost = context.argumentsHost;
119+
if (argumentsHost is HttpArgumentsHost) {
120+
final postId = argumentsHost.params['postId'];
121+
if (postId != null) {
122+
context.response.headers['x-post-id'] = postId.toString();
123+
}
124+
}
125+
return next();
126+
}
127+
}
128+
129+
class DynamicController extends Controller {
130+
DynamicController() : super('/posts/:postId') {
131+
on(Route.get('/comments'), (context) async => 'dynamic-route-ok');
132+
}
133+
}
134+
112135
void main() {
113136
group('$Middleware', () {
114137
SerinusApplication? app;
115138

116-
final module = TestModule(controllers: [TestController()]);
139+
final module = TestModule(
140+
controllers: [TestController(), DynamicController()],
141+
);
117142
setUpAll(() async {
118143
app = await serinus.createApplication(
119144
entrypoint: module,
@@ -196,5 +221,19 @@ void main() {
196221
expect(r.hasException, false);
197222
},
198223
);
224+
225+
test(
226+
'''when a controller has a dynamic base path, middleware should resolve params for matched requests''',
227+
() async {
228+
final request = await HttpClient().getUrl(
229+
Uri.parse('http://localhost:8888/posts/77/comments'),
230+
);
231+
final response = await request.close();
232+
final body = await response.transform(utf8.decoder).join();
233+
expect(response.statusCode, 200);
234+
expect(body, contains('dynamic-route-ok'));
235+
expect(response.headers.value('x-post-id'), '77');
236+
},
237+
);
199238
});
200239
}

packages/serinus/test/injector/explorer_test.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ void main() {
4646
);
4747

4848
test(
49-
'when the application startup, and a controller has not a static path, then the explorer will throw an error',
49+
'when the application startup, and a controller has a dynamic path, then the explorer should register matching routes',
5050
() async {
5151
final router = Router();
5252
final config = ApplicationConfig(
@@ -59,9 +59,12 @@ void main() {
5959
final container = SerinusContainer(config, _MockAdapter());
6060
final explorer = RoutesExplorer(container, router);
6161
await container.modulesContainer.registerModules(
62-
SimpleMockModule(controllers: [MockControllerWithWrongPath()]),
62+
SimpleMockModule(controllers: [MockControllerWithDynamicPath()]),
6363
);
64-
expect(() => explorer.resolveRoutes(), throwsException);
64+
explorer.resolveRoutes();
65+
final result = router.lookup('/42', HttpMethod.get);
66+
expect(result, isA<FoundRoute<RouterEntry>>());
67+
expect(result.params['id'], '42');
6568
},
6669
);
6770

packages/serinus/test/mocks/controller_mock.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ class MockController extends Controller {
77
}
88
}
99

10-
class MockControllerWithWrongPath extends Controller {
10+
class MockControllerWithDynamicPath extends Controller {
1111
@override
12-
MockControllerWithWrongPath([super.path = '/:id']) {
12+
MockControllerWithDynamicPath([super.path = '/:id']) {
1313
on(Route.get('/'), (context) => Future.value('Hello world'));
1414
}
1515
}

0 commit comments

Comments
 (0)