Skip to content

Commit 3692b93

Browse files
committed
wip
1 parent 0be851e commit 3692b93

File tree

7 files changed

+204
-19
lines changed

7 files changed

+204
-19
lines changed

packages/pharaoh/lib/src/_next/_core/core_impl.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ class _PharaohNextImpl implements Application {
2020
void useRoutes(RoutesResolver routeResolver) {
2121
final routes = routeResolver.call();
2222
routes.forEach((route) => route.commit(_spanner));
23+
24+
final openAPiRoutes = routes.fold(
25+
<OpenApiRoute>[], (preV, curr) => preV..addAll(curr.openAPIRoutes));
26+
27+
final result = OpenApiGenerator.generateOpenApi(
28+
openAPiRoutes,
29+
apiName: _appConfig.name,
30+
serverUrls: [_appConfig.url],
31+
);
32+
33+
File('openapi.json')
34+
.writeAsStringSync(JsonEncoder.withIndent(' ').convert(result));
2335
}
2436

2537
@override

packages/pharaoh/lib/src/_next/_core/reflector.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ ControllerMethod parseControllerMethod(ControllerMethodDefinition defn) {
9999
methods.firstWhereOrNull((e) => e.simpleName == symbolToString(method));
100100
if (actualMethod == null) {
101101
throw ArgumentError(
102-
'$type does not have method #${symbolToString(method)}');
102+
'$type does not have method #${symbolToString(method)}',
103+
);
103104
}
104105

105106
final parameters = actualMethod.parameters;

packages/pharaoh/lib/src/_next/_router/definition.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ class RouteMapping {
2323
}
2424
}
2525

26+
typedef OpenApiRoute = ({
27+
HTTPMethod method,
28+
String route,
29+
List<ControllerMethodParam> args,
30+
});
31+
2632
abstract class RouteDefinition {
2733
late RouteMapping route;
2834
final RouteDefinitionType type;
@@ -31,6 +37,8 @@ abstract class RouteDefinition {
3137

3238
void commit(Spanner spanner);
3339

40+
List<OpenApiRoute> get openAPIRoutes;
41+
3442
RouteDefinition _prefix(String prefix) => this..route = route.prefix(prefix);
3543
}
3644

@@ -69,6 +77,9 @@ class _MiddlewareDefinition extends RouteDefinition {
6977

7078
@override
7179
void commit(Spanner spanner) => spanner.addMiddleware(route.path, mdw);
80+
81+
@override
82+
List<OpenApiRoute> get openAPIRoutes => const [];
7283
}
7384

7485
typedef ControllerMethodDefinition = (Type controller, Symbol symbol);
@@ -121,6 +132,11 @@ class ControllerRouteMethodDefinition extends RouteDefinition {
121132
spanner.addRoute(routeMethod, route.path, useRequestHandler(handler));
122133
}
123134
}
135+
136+
@override
137+
List<OpenApiRoute> get openAPIRoutes => route.methods
138+
.map((e) => (route: route.path, method: e, args: method.params.toList()))
139+
.toList();
124140
}
125141

126142
class RouteGroupDefinition extends RouteDefinition {
@@ -169,6 +185,12 @@ class RouteGroupDefinition extends RouteDefinition {
169185
mdw.commit(spanner);
170186
}
171187
}
188+
189+
@override
190+
List<OpenApiRoute> get openAPIRoutes => defns.fold(
191+
[],
192+
(preV, c) => preV..addAll(c.openAPIRoutes),
193+
);
172194
}
173195

174196
typedef RequestHandlerWithApp = Function(
@@ -208,4 +230,8 @@ class FunctionalRouteDefinition extends RouteDefinition {
208230
spanner.addRoute<Middleware>(method, path, _requestHandler!);
209231
}
210232
}
233+
234+
@override
235+
List<OpenApiRoute> get openAPIRoutes =>
236+
[(args: [], method: method, route: route.path)];
211237
}

packages/pharaoh/lib/src/_next/_router/meta.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
part of '../router.dart';
22

3-
abstract class RequestAnnotation<T> {
3+
sealed class RequestAnnotation<T> {
44
final String? name;
55

66
const RequestAnnotation([this.name]);
@@ -61,7 +61,7 @@ class Body extends RequestAnnotation {
6161
}
6262

6363
final dtoInstance = methodParam.dto;
64-
if (dtoInstance != null) return dtoInstance..make(request);
64+
if (dtoInstance != null) return dtoInstance..validate(request);
6565

6666
final type = methodParam.type;
6767
if (type != dynamic && body.runtimeType != type) {

packages/pharaoh/lib/src/_next/_validation/dto.dart

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const dtoReflector = DtoReflector();
2020
abstract interface class _BaseDTOImpl {
2121
late Map<String, dynamic> data;
2222

23-
void make(Request request) {
23+
void validate(Request request) {
2424
data = const {};
2525
final (result, errors) = schema.validateSync(request.body ?? {});
2626
if (errors.isNotEmpty) {
@@ -29,15 +29,12 @@ abstract interface class _BaseDTOImpl {
2929
data = Map<String, dynamic>.from(result);
3030
}
3131

32-
EzSchema? _schemaCache;
33-
34-
EzSchema get schema {
35-
if (_schemaCache != null) return _schemaCache!;
36-
37-
final mirror = dtoReflector.reflectType(runtimeType) as r.ClassMirror;
38-
final properties = mirror.getters.where((e) => e.isAbstract);
39-
40-
final entries = properties.map((prop) {
32+
r.ClassMirror? _classMirrorCache;
33+
Iterable<({String name, Type type, ClassPropertyValidator meta})>
34+
get properties {
35+
_classMirrorCache ??=
36+
dtoReflector.reflectType(runtimeType) as r.ClassMirror;
37+
return _classMirrorCache!.getters.where((e) => e.isAbstract).map((prop) {
4138
final returnType = prop.reflectedReturnType;
4239
final meta =
4340
prop.metadata.whereType<ClassPropertyValidator>().firstOrNull ??
@@ -48,11 +45,23 @@ abstract interface class _BaseDTOImpl {
4845
'Type Mismatch between ${meta.runtimeType}(${meta.propertyType}) & $runtimeType class property ${prop.simpleName}->($returnType)');
4946
}
5047

51-
return MapEntry(meta.name ?? prop.simpleName, meta.validator);
48+
return (
49+
name: (meta.name ?? prop.simpleName),
50+
meta: meta,
51+
type: returnType,
52+
);
5253
});
54+
}
55+
56+
EzSchema? _schemaCache;
57+
EzSchema get schema {
58+
if (_schemaCache != null) return _schemaCache!;
59+
60+
final entriesToMap = properties.fold<Map<String, EzValidator<dynamic>>>(
61+
{},
62+
(prev, curr) => prev..[curr.name] = curr.meta.validator,
63+
);
5364

54-
final entriesToMap = entries.fold<Map<String, EzValidator<dynamic>>>(
55-
{}, (prev, curr) => prev..[curr.key] = curr.value);
5665
return _schemaCache = EzSchema.shape(entriesToMap);
5766
}
5867
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:pharaoh/src/_next/router.dart';
3+
4+
class OpenApiGenerator {
5+
static Map<String, dynamic> generateOpenApi(
6+
List<OpenApiRoute> routes, {
7+
required String apiName,
8+
required List<String> serverUrls,
9+
}) {
10+
return {
11+
"openapi": "3.0.0",
12+
"info": {"title": apiName, "version": "1.0.0"},
13+
"servers": serverUrls.map((e) => {'url': e}).toList(),
14+
"paths": _generatePaths(routes),
15+
"components": {"schemas": _generateSchemas(routes)}
16+
};
17+
}
18+
19+
static Map<String, dynamic> _generatePaths(List<OpenApiRoute> routes) {
20+
final paths = <String, Map<String, dynamic>>{};
21+
22+
for (final route in routes) {
23+
final pathParams = route.args.where((e) => e.meta is Param).toList();
24+
final bodyParam = route.args.firstWhereOrNull((e) => e is Body);
25+
26+
var path = route.route;
27+
// Convert Express-style path params (:id) to OpenAPI style ({id})
28+
for (final param in pathParams) {
29+
path = path.replaceAll('<${param.name}>', '{${param.name}}');
30+
}
31+
32+
paths[path] = paths[path] ?? {};
33+
paths[path]![route.method.name.toLowerCase()] = {
34+
"summary": "", // Could be added as a parameter
35+
"parameters": _generateParameters(route.args),
36+
"responses": {
37+
"200": {"description": "Successful response"}
38+
}
39+
};
40+
41+
if (bodyParam != null) {
42+
paths[path]![route.method.name.toLowerCase()]["requestBody"] = {
43+
"required": !bodyParam.optional,
44+
"content": {
45+
"application/json": {"schema": _generateSchema(bodyParam)}
46+
}
47+
};
48+
}
49+
}
50+
51+
return paths;
52+
}
53+
54+
static List<Map<String, dynamic>> _generateParameters(
55+
List<ControllerMethodParam> args,
56+
) {
57+
final parameters = <Map<String, dynamic>>[];
58+
59+
for (final arg in args) {
60+
final parameterLocation = _getParameterLocation(arg.meta);
61+
if (parameterLocation == null) continue;
62+
63+
final parameterSchema = _generateSchema(arg);
64+
65+
// Add default value if available and not a path parameter
66+
if (arg.defaultValue != null && parameterLocation != "path") {
67+
parameterSchema["default"] = arg.defaultValue;
68+
}
69+
70+
final param = {
71+
"name": arg.name,
72+
"in": parameterLocation,
73+
"required": parameterLocation == "path" ? true : !arg.optional,
74+
"schema": parameterSchema,
75+
};
76+
77+
parameters.add(param);
78+
}
79+
80+
return parameters;
81+
}
82+
83+
static String? _getParameterLocation(RequestAnnotation? annotation) {
84+
return switch (annotation) {
85+
const Header() => "header",
86+
const Query() => "query",
87+
const Param() => "path",
88+
_ => null,
89+
};
90+
}
91+
92+
static Map<String, dynamic> _generateSchema(ControllerMethodParam param) {
93+
if (param.dto != null) {
94+
return {
95+
"\$ref": "#/components/schemas/${param.dto.runtimeType.toString()}"
96+
};
97+
}
98+
99+
return _typeToOpenApiType(param.type);
100+
}
101+
102+
static Map<String, dynamic> _typeToOpenApiType(Type type) {
103+
switch (type.toString()) {
104+
case "String":
105+
return {"type": "string"};
106+
case "int":
107+
return {"type": "integer", "format": "int32"};
108+
case "double":
109+
return {"type": "number", "format": "double"};
110+
case "bool":
111+
return {"type": "boolean"};
112+
case "DateTime":
113+
return {"type": "string", "format": "date-time"};
114+
default:
115+
return {"type": "object"};
116+
}
117+
}
118+
119+
static Map<String, dynamic> _generateSchemas(List<OpenApiRoute> routes) {
120+
final schemas = <String, dynamic>{};
121+
122+
for (final route in routes) {
123+
for (final arg in route.args) {
124+
final dto = arg.dto;
125+
if (dto == null) continue;
126+
127+
schemas[dto.runtimeType.toString()] = {
128+
"type": "object",
129+
"properties": dto.properties.fold({},
130+
(preV, curr) => preV..[curr.name] = _typeToOpenApiType(curr.type))
131+
};
132+
}
133+
}
134+
135+
return schemas;
136+
}
137+
}

packages/pharaoh/test/pharaoh_next/validation/validation_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ void main() {
114114

115115
final app = pharaoh
116116
..post('/', (req, res) {
117-
dto.make(req);
117+
dto.validate(req);
118118
return res.json({
119119
'firstname': dto.username,
120120
'lastname': dto.lastname,
@@ -147,7 +147,7 @@ void main() {
147147

148148
final app = pharaoh
149149
..post('/optional', (req, res) {
150-
dto.make(req);
150+
dto.validate(req);
151151

152152
return res.json({
153153
'nationality': dto.nationality,
@@ -197,7 +197,7 @@ void main() {
197197
final dto = DTOTypeMismatch();
198198

199199
pharaoh.post('/type-mismatch', (req, res) {
200-
dto.make(req);
200+
dto.validate(req);
201201
return res.ok('Foo Bar');
202202
});
203203

0 commit comments

Comments
 (0)