Skip to content

Commit d9bca6a

Browse files
committed
feat: implement open api reporter
1 parent 080bdd8 commit d9bca6a

18 files changed

+393
-24
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.2.0
2+
- Move rules from handlers to dedicated classes
3+
- Implement OpenAPI reporter
4+
15
## 1.1.0
26
- Implement `VineBasics` validation rules
37
- Implement `VineGroup` validation rules

README.md

+33-10
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ ensuring that data complies with an expected format before it is used, which red
2121
| 🚧 Null Safety | Full support for nullable and optional fields |
2222
| ⚙️ Composable | Compiled and reusable schemas |
2323
| ⚡ Fast Performance | ~ 29 000 000 ops/s |
24-
| 📦 Extremely small size | Package size `< 20kb` |
24+
| 📦 Extremely small size | Package size `< 21kb` |
25+
| 🚀 OpenApi reporter | Export your schemas as OpenApi spec |
2526

2627
## 🚀 Usage
2728

2829
Vine is a data structure validation library for Dart. You may use it to validate the HTTP request body or any data in
29-
your
30-
backend applications.
30+
your backend applications.
3131

3232
### Built for validating form data and JSON payloads
3333

@@ -57,13 +57,13 @@ import 'package:vine/vine.dart';
5757
5858
void main() {
5959
final validator = vine.compile(
60-
vine.object({
61-
'username': vine.string().minLength(3).maxLength(20),
62-
'email': vine.string().email(),
63-
'age': vine.number().min(18).optional(),
64-
'isAdmin': vine.boolean(),
65-
'features': vine.array(vine.string()),
66-
}));
60+
vine.object({
61+
'username': vine.string().minLength(3).maxLength(20),
62+
'email': vine.string().email(),
63+
'age': vine.number().min(18).optional(),
64+
'isAdmin': vine.boolean(),
65+
'features': vine.array(vine.string()),
66+
}));
6767
6868
try {
6969
final payload = {
@@ -82,6 +82,29 @@ void main() {
8282
}
8383
```
8484

85+
### OpenAPI reporter
86+
87+
Vine can generate an OpenAPI schema from your validation schemas.
88+
This feature is useful when you want to document your API
89+
90+
```dart
91+
final schema = vine.object({
92+
'stringField': vine.string().minLength(3).maxLength(20),
93+
'emailField': vine.string().email(),
94+
'numberField': vine.number().min(18).max(100),
95+
'booleanField': vine.boolean(),
96+
'enumField': vine.enumerate(MyEnum.values),
97+
'arrayField': vine.array(vine.string().minLength(3).maxLength(20)).minLength(1),
98+
'unionField': vine.union([
99+
vine.string().minLength(3).maxLength(20),
100+
vine.number().min(10).max(20),
101+
]),
102+
});
103+
104+
final reporter = vine.openApi.report(schemas: {'MySchema': schema});
105+
print(reporter);
106+
```
107+
85108
## ❤️ Credit
86109

87110
I would like to thank [Harminder Virk](https://github.com/thetutlage) for all his open-source work on Adonis.js and for

lib/src/contracts/schema.dart

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import 'package:vine/src/contracts/vine.dart';
2+
import 'package:vine/src/introspection.dart';
23
import 'package:vine/src/schema/object/object_schema.dart';
34

4-
abstract interface class VineSchema<T extends ErrorReporter> {
5+
abstract interface class VineSchema<T extends ErrorReporter> implements SchemaIntrospection {
56
/// Validate the field [field] the field to validate
67
void parse(VineValidationContext ctx, FieldContext field);
78

lib/src/introspection.dart

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import 'package:vine/src/contracts/schema.dart';
2+
3+
abstract interface class SchemaIntrospection {
4+
Map<String, dynamic> introspect({String? name});
5+
}
6+
7+
final class OpenApiReporter {
8+
Map<String, dynamic> report(
9+
{required Map<String, VineSchema> schemas, String version = '3.1.0'}) {
10+
final components = <String, dynamic>{};
11+
12+
for (final entry in schemas.entries) {
13+
components[entry.key] = entry.value.introspect(name: entry.key);
14+
}
15+
16+
return {
17+
'openapi': version,
18+
'components': {'schemas': components},
19+
};
20+
}
21+
}

lib/src/schema/any_schema.dart

+7
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,11 @@ final class VineAnySchema extends RuleParser implements VineAny {
5454
VineAny clone() {
5555
return VineAnySchema(Queue.of(rules));
5656
}
57+
58+
@override
59+
Map<String, dynamic> introspect({String? name}) {
60+
return {
61+
'required': !isOptional,
62+
};
63+
}
5764
}

lib/src/schema/array_schema.dart

+32
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:collection';
22

3+
import 'package:vine/src/contracts/rule.dart';
34
import 'package:vine/src/contracts/schema.dart';
45
import 'package:vine/src/contracts/vine.dart';
56
import 'package:vine/src/rule_parser.dart';
@@ -79,4 +80,35 @@ final class VineArraySchema extends RuleParser implements VineArray {
7980
VineArray clone() {
8081
return VineArraySchema(Queue.of(rules));
8182
}
83+
84+
int? _getRuleValue<T extends VineRule>() {
85+
return switch (rules.whereType<T>().firstOrNull) {
86+
VineArrayMinLengthRule rule => rule.minValue,
87+
VineArrayFixedLengthRule rule => rule.count,
88+
_ => null,
89+
};
90+
}
91+
92+
@override
93+
Map<String, dynamic> introspect({String? name}) {
94+
final itemsSchema = rules.whereType<VineArrayRule>().firstOrNull?.schema.introspect();
95+
itemsSchema?.remove('required');
96+
final example = itemsSchema?['example'];
97+
itemsSchema?.remove('example');
98+
99+
100+
final minValue = _getRuleValue<VineArrayMinLengthRule>();
101+
final maxValue = _getRuleValue<VineArrayMaxLengthRule>();
102+
final isUnique = rules.whereType<VineArrayUniqueRule>().isNotEmpty;
103+
104+
return {
105+
'type': 'array',
106+
'items': itemsSchema ?? {'type': 'any'},
107+
if (minValue != null) 'minItems': minValue,
108+
if (maxValue != null) 'maxItems': maxValue,
109+
if (isUnique) 'uniqueItems': isUnique,
110+
'required': !isOptional,
111+
'example': [example],
112+
};
113+
}
82114
}

lib/src/schema/boolean_schema.dart

+9
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,13 @@ final class VineBooleanSchema extends RuleParser implements VineBoolean {
5454
VineBoolean clone() {
5555
return VineBooleanSchema(Queue.of(rules));
5656
}
57+
58+
@override
59+
Map<String, dynamic> introspect({String? name}) {
60+
return {
61+
'type': 'boolean',
62+
'required': !isOptional,
63+
'example': true,
64+
};
65+
}
5766
}

lib/src/schema/date_schema.dart

+10
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,14 @@ final class VineDateSchema extends RuleParser implements VineDate {
9191
VineDate clone() {
9292
return VineDateSchema(Queue.of(rules));
9393
}
94+
95+
@override
96+
Map<String, dynamic> introspect({String? name}) {
97+
return {
98+
'type': 'string',
99+
'format': 'date-time',
100+
'required': !isOptional,
101+
'example': DateTime.now().toIso8601String(),
102+
};
103+
}
94104
}

lib/src/schema/enum_schema.dart

+14-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import 'package:vine/src/rule_parser.dart';
55
import 'package:vine/src/rules/basic_rule.dart';
66
import 'package:vine/vine.dart';
77

8-
final class VineEnumSchema extends RuleParser implements VineEnum {
9-
VineEnumSchema(super._rules);
8+
final class VineEnumSchema<T extends VineEnumerable> extends RuleParser implements VineEnum {
9+
final List<T> _source;
10+
VineEnumSchema(super._rules, this._source);
1011

1112
@override
1213
VineEnum requiredIfExist(List<String> values) {
@@ -52,6 +53,16 @@ final class VineEnumSchema extends RuleParser implements VineEnum {
5253

5354
@override
5455
VineEnum clone() {
55-
return VineEnumSchema(Queue.of(rules));
56+
return VineEnumSchema(Queue.of(rules), _source.toList());
57+
}
58+
59+
@override
60+
Map<String, dynamic> introspect({String? name}) {
61+
return {
62+
'type': 'string', // Adapt selon le type des valeurs
63+
'enum': _source.map((e) => e.value).toList(),
64+
'required': !isOptional,
65+
'example': _source.firstOrNull?.value,
66+
};
5667
}
5768
}

lib/src/schema/number_schema.dart

+57
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,61 @@ final class VineNumberSchema extends RuleParser implements VineNumber {
9797
VineNumber clone() {
9898
return VineNumberSchema(Queue.of(rules));
9999
}
100+
101+
@override
102+
Map<String, dynamic> introspect({String? name}) {
103+
final validations = <String, dynamic>{};
104+
bool isInteger = false;
105+
bool isDouble = false;
106+
List<num>? enums;
107+
num example = 0;
108+
109+
// Analyse des règles
110+
for (final rule in rules) {
111+
switch (rule) {
112+
case VineIntegerRule():
113+
isInteger = true;
114+
example = 42;
115+
case VineDoubleRule():
116+
isDouble = true;
117+
example = 3.14;
118+
case VineMinRule(:final minValue):
119+
validations['minimum'] = minValue;
120+
example = minValue + (isInteger ? 1 : 0.5);
121+
case VineMaxRule(:final maxValue):
122+
validations['maximum'] = maxValue;
123+
example = maxValue - (isInteger ? 1 : 0.5);
124+
case VineRangeRule(:final values):
125+
enums = values;
126+
example = values.firstOrNull ?? example;
127+
case VinePositiveRule():
128+
validations['exclusiveMinimum'] = 0;
129+
example = 1;
130+
case VineNegativeRule():
131+
validations['exclusiveMaximum'] = 0;
132+
example = -1;
133+
}
134+
}
135+
136+
// Détermination du type
137+
final type = isInteger ? 'integer' : isDouble ? 'number' : 'number';
138+
139+
// Validation de l'exemple
140+
if (validations.containsKey('minimum') || validations.containsKey('maximum')) {
141+
example = example.clamp(
142+
validations['minimum'] ?? '-Infinity',
143+
validations['maximum'] ?? 'Infinity',
144+
);
145+
}
146+
147+
// Construction du schéma
148+
return {
149+
if (name != null) 'title': name,
150+
'type': type,
151+
'required': !isOptional,
152+
if (enums != null) 'enum': enums,
153+
'example': example,
154+
...validations,
155+
}..removeWhere((_, v) => v == null);
156+
}
100157
}

lib/src/schema/object/group_schema.dart

+5
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,9 @@ final class VineGroupSchema extends RuleParser implements VineGroup {
2222
VineGroup clone() {
2323
return VineGroupSchema(Queue.of(rules));
2424
}
25+
26+
@override
27+
Map<String, dynamic> introspect({String? name}) {
28+
return {};
29+
}
2530
}

lib/src/schema/object/object_schema.dart

+28
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,32 @@ final class VineObjectSchema extends RuleParser implements VineObject {
6565
VineObject clone() {
6666
return VineObjectSchema({..._properties}, Queue.of(rules));
6767
}
68+
69+
@override
70+
Map<String, dynamic> introspect({String? name}) {
71+
final Map<String, dynamic> properties = {};
72+
final List<String> requiredFields = [];
73+
final Map<String, dynamic> example = {};
74+
75+
for (final entry in _properties.entries) {
76+
final schema = entry.value.introspect();
77+
properties[entry.key] = schema;
78+
79+
if (schema['required'] ?? false) {
80+
requiredFields.add(entry.key);
81+
schema.remove('required');
82+
}
83+
84+
example[entry.key] = schema['example'] ?? (schema['examples'] as List).firstOrNull;
85+
}
86+
87+
return {
88+
if (name != null) 'title': name,
89+
'type': 'object',
90+
'properties': properties,
91+
if (requiredFields.isNotEmpty) 'required': requiredFields,
92+
'additionalProperties': false,
93+
'example': example,
94+
};
95+
}
6896
}

lib/src/schema/string_schema.dart

+40-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import 'dart:collection';
22

3-
import 'package:vine/src/contracts/schema.dart';
4-
import 'package:vine/src/rule_parser.dart';
5-
import 'package:vine/src/rules/basic_rule.dart';
6-
import 'package:vine/src/rules/string_rule.dart';
73
import 'package:vine/vine.dart';
84

95
final class VineStringSchema extends RuleParser implements VineString {
@@ -197,4 +193,44 @@ final class VineStringSchema extends RuleParser implements VineString {
197193
VineString clone() {
198194
return VineStringSchema(Queue.of(rules));
199195
}
196+
197+
@override
198+
Map<String, dynamic> introspect({String? name}) {
199+
final validations = <String, dynamic>{};
200+
String? format;
201+
String example = 'foo';
202+
List<String>? enums;
203+
204+
for (final rule in rules) {
205+
if (rule is VineEmailRule) {
206+
format = 'email';
207+
example = '[email protected]';
208+
continue;
209+
}
210+
211+
if (rule is VineUuidRule) {
212+
format = 'uuid';
213+
example = '550e8400-e29b-41d4-a716-446655440000';
214+
continue;
215+
}
216+
217+
if (rule is VineMinLengthRule) {
218+
validations['minLength'] = rule.minValue;
219+
continue;
220+
}
221+
222+
if (rule is VineEnumRule) {
223+
enums = rule.source.map((e) => e.value.toString()).toList();
224+
}
225+
}
226+
227+
return {
228+
'type': 'string',
229+
'format': format,
230+
'required': !isOptional,
231+
'example': example,
232+
'enum': enums,
233+
...validations,
234+
}..removeWhere((_, v) => v == null);
235+
}
200236
}

0 commit comments

Comments
 (0)