Skip to content

Commit b454a13

Browse files
committed
feat: Add file validation with l.file() and @IsFile code generation support
1 parent 8e10166 commit b454a13

File tree

17 files changed

+638
-34
lines changed

17 files changed

+638
-34
lines changed

docs/astro.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export default defineConfig({
7373
label: 'String',
7474
link: '/single-values/string',
7575
},
76+
{
77+
label: 'File',
78+
link: '/single-values/file',
79+
},
7680
{
7781
label: 'Null',
7882
link: '/single-values/null',

docs/src/content/docs/schemas/code-generation.mdx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,33 @@ The generator automatically:
503503
- Omits `.required()` from element validators when elements are nullable (`List<T?>`)
504504
- Makes the list itself optional when the field is nullable (`List<T>?`)
505505

506+
## File Validation with Code Generation
507+
508+
Use `@IsFile()` (or `@isFile`) when a field should be validated with `l.file()`.
509+
510+
```dart
511+
@luthor
512+
@freezed
513+
abstract class FileUpload with _$FileUpload {
514+
const factory FileUpload({
515+
@IsFile() required Object profileImage,
516+
String? description,
517+
}) = _FileUpload;
518+
519+
factory FileUpload.fromJson(Map<String, dynamic> json) =>
520+
_$FileUploadFromJson(json);
521+
}
522+
```
523+
524+
### Generated File Validation
525+
526+
```dart
527+
Validator $FileUploadSchema = l.withName('FileUpload').schema({
528+
FileUploadSchemaKeys.profileImage: l.file().required(),
529+
FileUploadSchemaKeys.description: l.string(),
530+
});
531+
```
532+
506533
## Auto-Generation for Classes Without @luthor
507534

508535
The code generator can automatically generate schemas for classes that don't have the `@luthor` annotation, as long as they meet certain compatibility requirements:
@@ -640,4 +667,4 @@ Validator $SignupFormSchema = l.withName('SignupForm').schema({
640667
4. **Automatic Detection** - Let the generator handle self-references automatically; only use `@luthorForwardRef` for cross-class circular references
641668
5. **Run Generation** - Run `dart run build_runner build` after making changes to annotated classes
642669

643-
The code generation features make Luthor validation both more powerful and safer to use, providing compile-time guarantees and excellent IDE support for your validation logic.
670+
The code generation features make Luthor validation both more powerful and safer to use, providing compile-time guarantees and excellent IDE support for your validation logic.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
title: Validating a File value
3+
---
4+
5+
import CodeGenContentSwitcher from '../../../components/CodeGenContentSwitcher.astro'
6+
7+
<CodeGenContentSwitcher>
8+
<div slot="false">
9+
```dart
10+
import 'dart:typed_data';
11+
12+
import 'package:luthor/luthor.dart';
13+
14+
void main() {
15+
final validator = l.file();
16+
print(validator.validateValue(Uint8List.fromList([1, 2, 3]))); // Success
17+
print(validator.validateValue('not-a-file')); // Error
18+
}
19+
```
20+
</div>
21+
22+
<div slot="true">
23+
```dart
24+
import 'dart:typed_data';
25+
26+
import 'package:freezed_annotation/freezed_annotation.dart';
27+
import 'package:luthor/luthor.dart';
28+
29+
part 'file_schema.freezed.dart';
30+
part 'file_schema.g.dart';
31+
32+
@luthor
33+
@freezed
34+
abstract class FileSchema with _$FileSchema {
35+
const factory FileSchema({
36+
@isFile /* OR @IsFile() */ required Object profileImage,
37+
String? description,
38+
}) = _FileSchema;
39+
40+
factory FileSchema.fromJson(Map<String, dynamic> json) =>
41+
_$FileSchemaFromJson(json);
42+
}
43+
44+
void main() {
45+
final result = $FileSchemaValidate({
46+
'profileImage': Uint8List.fromList([1, 2, 3]),
47+
'description': 'Avatar upload',
48+
});
49+
50+
switch (result) {
51+
case SchemaValidationSuccess(data: final data):
52+
print('✅ Valid: ${data.description}');
53+
case SchemaValidationError(errors: final errors):
54+
print('❌ Errors: $errors');
55+
}
56+
}
57+
```
58+
</div>
59+
</CodeGenContentSwitcher>
60+
61+
`l.file()` validates common file-like values such as `File`, `MultipartFile`, `XFile`, `Uint8List`, and `ByteData`.
62+
63+
When using code generation, apply `@IsFile()` (or `@isFile`) to fields that should use file validation.

packages/luthor/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 0.17.0
2+
3+
- **FEAT**: Add support for validating files with `l.file()` validator.
4+
15
# 0.16.0
26

37
- **FEAT**: Update version to match luthor_generator.

packages/luthor/lib/luthor.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export 'src/annotations/validators/date_time.dart';
88
export 'src/annotations/validators/email.dart';
99
export 'src/annotations/validators/emoji.dart';
1010
export 'src/annotations/validators/ends_with.dart';
11+
export 'src/annotations/validators/file.dart';
1112
export 'src/annotations/validators/forward_ref.dart';
1213
export 'src/annotations/validators/ip.dart';
1314
export 'src/annotations/validators/length.dart';
@@ -27,6 +28,7 @@ export 'src/validations/any_validation.dart';
2728
export 'src/validations/bool_validation.dart';
2829
export 'src/validations/custom_validation.dart';
2930
export 'src/validations/double_validation.dart';
31+
export 'src/validations/file_validation.dart';
3032
export 'src/validations/int_validation.dart';
3133
export 'src/validations/list_validation.dart';
3234
export 'src/validations/map_validation.dart';
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class IsFile {
2+
final String? message;
3+
final String? Function()? messageFn;
4+
5+
const IsFile({this.message, this.messageFn});
6+
}
7+
8+
const isFile = IsFile();
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import 'package:luthor/src/validation.dart';
2+
3+
class FileValidation extends Validation {
4+
String? customMessage;
5+
String? Function()? customMessageFn;
6+
7+
FileValidation({String? message, String? Function()? messageFn})
8+
: customMessage = message,
9+
customMessageFn = messageFn;
10+
11+
@override
12+
bool call(String? fieldName, Object? value) {
13+
super.call(fieldName, value);
14+
if (value == null) return false;
15+
16+
final typeName = value.runtimeType.toString().toLowerCase();
17+
return typeName.contains('file') ||
18+
typeName.contains('multipart') ||
19+
typeName.contains('xfile') ||
20+
typeName.contains('stream') ||
21+
typeName.contains('bytes') ||
22+
typeName.contains('uint8list') ||
23+
typeName.contains('bytedata');
24+
}
25+
26+
@override
27+
String get message =>
28+
customMessage ??
29+
customMessageFn?.call() ??
30+
'${fieldName ?? 'value'} must be a file';
31+
32+
@override
33+
Map<String, List<String>>? get errors => null;
34+
}

packages/luthor/lib/src/validator.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:luthor/src/validations/any_validation.dart';
44
import 'package:luthor/src/validations/bool_validation.dart';
55
import 'package:luthor/src/validations/custom_validation.dart';
66
import 'package:luthor/src/validations/double_validation.dart';
7+
import 'package:luthor/src/validations/file_validation.dart';
78
import 'package:luthor/src/validations/int_validation.dart';
89
import 'package:luthor/src/validations/list_validation.dart';
910
import 'package:luthor/src/validations/map_validation.dart';
@@ -181,6 +182,17 @@ class Validator implements ValidatorReference {
181182
return newValidator;
182183
}
183184

185+
/// Validates that the value is a file.
186+
Validator file({String? message, String? Function()? messageFn}) {
187+
final newValidations = List<Validation>.from(validations)
188+
..add(FileValidation(message: message, messageFn: messageFn));
189+
final newValidator = Validator(initialValidations: newValidations);
190+
if (_name != null) {
191+
newValidator._name = _name;
192+
}
193+
return newValidator;
194+
}
195+
184196
/// Validates that the value is a list.
185197
Validator list({
186198
List<ValidatorReference>? validators,

packages/luthor/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: luthor
22
description: |
33
A Dart validation library inspired by https://zod.dev
44
with support for code generation.
5-
version: 0.16.0
5+
version: 0.17.0
66
repository: https://github.com/exaby73/luthor/tree/main/packages/luthor
77
homepage: https://luthor.ex3.dev
88
resolution: workspace
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'dart:io';
2+
import 'dart:typed_data';
3+
4+
import 'package:luthor/luthor.dart';
5+
import 'package:test/test.dart';
6+
7+
void main() {
8+
test('should validate File instances', () {
9+
final result = l.file().validateValue(File('avatar.png'));
10+
11+
switch (result) {
12+
case SingleValidationSuccess(data: final data):
13+
expect(data, isA<File>());
14+
case SingleValidationError(data: _, errors: final errors):
15+
fail('expected success but got errors: $errors');
16+
}
17+
});
18+
19+
test('should validate byte arrays as file-like values', () {
20+
final result = l.file().validateValue(Uint8List.fromList([1, 2, 3]));
21+
22+
switch (result) {
23+
case SingleValidationSuccess(data: final data):
24+
expect(data, isA<Uint8List>());
25+
case SingleValidationError(data: _, errors: final errors):
26+
fail('expected success but got errors: $errors');
27+
}
28+
});
29+
30+
test('should fail for null values', () {
31+
final result = l.file().validateValue(null);
32+
33+
switch (result) {
34+
case SingleValidationSuccess(data: _):
35+
fail('should not be a success');
36+
case SingleValidationError(data: _, errors: final errors):
37+
expect(errors, ['value must be a file']);
38+
}
39+
});
40+
41+
test('should fail for non file-like values', () {
42+
final result = l.file().validateValue('not-a-file');
43+
44+
switch (result) {
45+
case SingleValidationSuccess(data: _):
46+
fail('should not be a success');
47+
case SingleValidationError(data: _, errors: final errors):
48+
expect(errors, ['value must be a file']);
49+
}
50+
});
51+
52+
test('should use custom message when message is provided', () {
53+
final result = l
54+
.file(message: 'profileImage must be a file')
55+
.validateValue('not-a-file');
56+
57+
switch (result) {
58+
case SingleValidationSuccess(data: _):
59+
fail('should not be a success');
60+
case SingleValidationError(data: _, errors: final errors):
61+
expect(errors, ['profileImage must be a file']);
62+
}
63+
});
64+
65+
test('should use messageFn when provided', () {
66+
final result = l
67+
.file(messageFn: () => 'dynamic file error')
68+
.validateValue('not-a-file');
69+
70+
switch (result) {
71+
case SingleValidationSuccess(data: _):
72+
fail('should not be a success');
73+
case SingleValidationError(data: _, errors: final errors):
74+
expect(errors, ['dynamic file error']);
75+
}
76+
});
77+
}

0 commit comments

Comments
 (0)