@kanun-hq/plugin-file 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Toneflix, LLC. and Arkstack Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,670 @@
1
+ # @kanun-hq/plugin-file
2
+
3
+ File validation plugin for Kanun.
4
+
5
+ `@kanun-hq/plugin-file` adds first-class file rules, request-context helpers, framework adapters, and wildcard utilities for upload-heavy validation flows.
6
+
7
+ ## Features
8
+
9
+ - Adds `file`, `files`, `image`, `extensions`, `mimes`, `mimetypes`, and `dimensions` rules
10
+ - Extends built-in `min`, `max`, and `size` so file sizes are measured in kilobytes
11
+ - Supports direct file values, request-scoped uploads, and wildcard multi-file validation
12
+ - Ships adapters for Express, Fastify, Hono, and h3
13
+ - Supports custom file resolution through `createFileValidatorPlugin()`
14
+ - Returns validated files from `await validator.validate()`
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pnpm add kanun @kanun-hq/plugin-file
20
+ ```
21
+
22
+ ```bash
23
+ npm install kanun @kanun-hq/plugin-file
24
+ ```
25
+
26
+ ```bash
27
+ yarn add kanun @kanun-hq/plugin-file
28
+ ```
29
+
30
+ ## Registering The Plugin
31
+
32
+ Register the plugin once during application startup.
33
+
34
+ ```ts
35
+ import { Validator } from 'kanun';
36
+ import { fileValidatorPlugin } from '@kanun-hq/plugin-file';
37
+
38
+ Validator.use(fileValidatorPlugin);
39
+ ```
40
+
41
+ If you need custom file lookup behavior, register your own instance instead:
42
+
43
+ ```ts
44
+ import { Validator } from 'kanun';
45
+ import { createFileValidatorPlugin } from '@kanun-hq/plugin-file';
46
+
47
+ Validator.use(
48
+ createFileValidatorPlugin({
49
+ resolveFiles: ({ attribute, context, value }) => {
50
+ if (typeof value !== 'undefined') {
51
+ return value;
52
+ }
53
+
54
+ return context.requestFiles?.[attribute];
55
+ },
56
+ }),
57
+ );
58
+ ```
59
+
60
+ ## Supported File Shapes
61
+
62
+ The plugin accepts file-like values that expose one or more of these properties:
63
+
64
+ - `buffer`
65
+ - `filename`
66
+ - `originalname`
67
+ - `name`
68
+ - `path`
69
+ - `mimetype`
70
+ - `type`
71
+ - `size`
72
+ - `width`
73
+ - `height`
74
+
75
+ That means these input styles work:
76
+
77
+ - Multer-style file objects
78
+ - Fastify multipart parts after normalization
79
+ - plain objects with `path`, `size`, and `mimetype`
80
+ - `File` and `Blob` values from `FormData`
81
+ - arrays of any supported file shape
82
+
83
+ Example direct file value:
84
+
85
+ ```ts
86
+ const avatar = {
87
+ buffer,
88
+ mimetype: 'image/png',
89
+ originalname: 'avatar.png',
90
+ size: 2048,
91
+ };
92
+
93
+ const validator = Validator.make(
94
+ { avatar },
95
+ { avatar: 'file|image|extensions:png|max:4' },
96
+ );
97
+
98
+ const passes = await validator.passes();
99
+ ```
100
+
101
+ ## Rule Reference
102
+
103
+ ### `file`
104
+
105
+ Validates that the value resolves to exactly one file.
106
+
107
+ ```ts
108
+ const validator = Validator.make({ avatar }, { avatar: 'file' });
109
+ ```
110
+
111
+ ### `files`
112
+
113
+ Validates that the value resolves to an array of files.
114
+
115
+ ```ts
116
+ const validator = Validator.make({ attachments }, { attachments: 'files' });
117
+ ```
118
+
119
+ ### `image`
120
+
121
+ Validates that all resolved files are images.
122
+
123
+ Image detection succeeds when either of these is true:
124
+
125
+ - the MIME type starts with `image/`
126
+ - the file extension is a known image extension such as `png`, `jpg`, `gif`, `webp`, or `svg`
127
+
128
+ ```ts
129
+ const validator = Validator.make({ avatar }, { avatar: 'file|image' });
130
+ ```
131
+
132
+ ### `extensions:...`
133
+
134
+ Validates the filename or path suffix.
135
+
136
+ ```ts
137
+ const validator = Validator.make(
138
+ { avatar },
139
+ { avatar: 'file|extensions:png,jpg,jpeg' },
140
+ );
141
+ ```
142
+
143
+ Use this when your rule should match the actual extension directly.
144
+
145
+ ### `mimes:...`
146
+
147
+ Validates by resolved extension using the familiar Laravel-style rule syntax.
148
+
149
+ ```ts
150
+ const validator = Validator.make({ avatar }, { avatar: 'file|mimes:png,jpg' });
151
+ ```
152
+
153
+ Use this when you want extension-like validation but prefer the conventional `mimes` rule name.
154
+
155
+ ### `mimetypes:...`
156
+
157
+ Validates the file MIME type.
158
+
159
+ ```ts
160
+ const validator = Validator.make(
161
+ { avatar },
162
+ { avatar: 'file|mimetypes:image/png,image/jpeg' },
163
+ );
164
+ ```
165
+
166
+ ### `dimensions:...`
167
+
168
+ Validates image dimensions using any combination of:
169
+
170
+ - `min_width`
171
+ - `max_width`
172
+ - `min_height`
173
+ - `max_height`
174
+ - `ratio`
175
+
176
+ `ratio` can be written as a number or fraction.
177
+
178
+ ```ts
179
+ const validator = Validator.make(
180
+ { avatar },
181
+ {
182
+ avatar:
183
+ 'file|image|dimensions:min_width=256,min_height=256,max_width=2048,max_height=2048,ratio=1',
184
+ },
185
+ );
186
+ ```
187
+
188
+ ```ts
189
+ const validator = Validator.make(
190
+ { banner },
191
+ {
192
+ banner: 'file|image|dimensions:min_width=1200,ratio=3/1',
193
+ },
194
+ );
195
+ ```
196
+
197
+ Dimension lookup works in this order:
198
+
199
+ - explicit `width` and `height` on the file object
200
+ - image data from `buffer`
201
+ - image data from `path`
202
+
203
+ ## File Size Rules
204
+
205
+ The plugin extends Kanun's built-in `min`, `max`, and `size` rules for file values.
206
+
207
+ All file sizes are interpreted in kilobytes.
208
+
209
+ ```ts
210
+ const validator = Validator.make({ avatar }, { avatar: 'file|min:1|max:2048' });
211
+ ```
212
+
213
+ ```ts
214
+ const validator = Validator.make({ avatar }, { avatar: 'file|size:512' });
215
+ ```
216
+
217
+ ## Validating Files Passed Directly In Data
218
+
219
+ If your application already has file objects on the data payload, you can validate them without any context helpers.
220
+
221
+ ```ts
222
+ const validator = Validator.make(
223
+ {
224
+ avatar: {
225
+ buffer,
226
+ mimetype: 'image/png',
227
+ originalname: 'avatar.png',
228
+ size: 2048,
229
+ },
230
+ },
231
+ {
232
+ avatar: 'file|image|extensions:png|mimetypes:image/png|max:4',
233
+ },
234
+ );
235
+
236
+ await validator.validate();
237
+ ```
238
+
239
+ ## Validating Request-Scoped Uploads With `.withContext()`
240
+
241
+ If files are not part of the data object, attach them through validator context.
242
+
243
+ ```ts
244
+ const validator = Validator.make(
245
+ {},
246
+ { avatar: 'file|image|mimes:png' },
247
+ ).withContext({
248
+ requestFiles: {
249
+ avatar,
250
+ },
251
+ });
252
+
253
+ const validated = await validator.validate();
254
+ ```
255
+
256
+ `validated` will include the file:
257
+
258
+ ```ts
259
+ {
260
+ avatar,
261
+ }
262
+ ```
263
+
264
+ ## Global Request Context With `Validator.useContext()`
265
+
266
+ If you register request-scoped data in middleware and create validators later in the same request lifecycle, use the static API.
267
+
268
+ ```ts
269
+ import { Validator } from 'kanun';
270
+
271
+ Validator.useContext({
272
+ requestFiles: {
273
+ avatar,
274
+ },
275
+ });
276
+
277
+ const validator = Validator.make({}, { avatar: 'file|image|mimes:png' });
278
+ const validated = await validator.validate();
279
+ ```
280
+
281
+ ## Returning Validated Files From `validate()`
282
+
283
+ `await validator.validate()` returns the validated values for rule keys.
284
+
285
+ That includes:
286
+
287
+ - files supplied directly in `data`
288
+ - files resolved from `context.requestFiles`
289
+ - file collections mirrored into data for wildcard validation
290
+
291
+ Example:
292
+
293
+ ```ts
294
+ const validator = Validator.make(
295
+ {},
296
+ { avatar: 'file|image|extensions:png' },
297
+ ).withContext({
298
+ requestFiles: {
299
+ avatar,
300
+ },
301
+ });
302
+
303
+ const validated = await validator.validate();
304
+
305
+ validated.avatar;
306
+ ```
307
+
308
+ ## Express
309
+
310
+ ### Middleware-style usage
311
+
312
+ Use `useExpressUploadContext()` after middleware such as Multer has populated `req.file` or `req.files`.
313
+
314
+ ```ts
315
+ import express from 'express';
316
+ import multer from 'multer';
317
+ import { Validator } from 'kanun';
318
+ import {
319
+ fileValidatorPlugin,
320
+ useExpressUploadContext,
321
+ } from '@kanun-hq/plugin-file';
322
+
323
+ Validator.use(fileValidatorPlugin);
324
+
325
+ const app = express();
326
+ const upload = multer({ storage: multer.memoryStorage() });
327
+
328
+ app.post('/profile', upload.single('avatar'), async (req, res) => {
329
+ useExpressUploadContext(req);
330
+
331
+ const validator = Validator.make(
332
+ {},
333
+ {
334
+ avatar: 'file|image|mimes:png,jpg|max:2048',
335
+ },
336
+ );
337
+
338
+ const validated = await validator.validate();
339
+ res.json(validated);
340
+ });
341
+ ```
342
+
343
+ ### Single validator instance usage
344
+
345
+ Use `withExpressUploadContext()` when you only want to enrich one validator instance.
346
+
347
+ ```ts
348
+ import { withExpressUploadContext } from '@kanun-hq/plugin-file';
349
+
350
+ const validator = withExpressUploadContext(
351
+ Validator.make({}, { avatar: 'file|image|mimes:png' }),
352
+ req,
353
+ );
354
+
355
+ await validator.validate();
356
+ ```
357
+
358
+ ## Fastify
359
+
360
+ ### Hook or route usage
361
+
362
+ Use `useFastifyUploadContext()` with `@fastify/multipart`.
363
+
364
+ ```ts
365
+ import Fastify from 'fastify';
366
+ import multipart from '@fastify/multipart';
367
+ import { Validator } from 'kanun';
368
+ import {
369
+ fileValidatorPlugin,
370
+ useFastifyUploadContext,
371
+ } from '@kanun-hq/plugin-file';
372
+
373
+ Validator.use(fileValidatorPlugin);
374
+
375
+ const app = Fastify();
376
+ await app.register(multipart);
377
+
378
+ app.addHook('preHandler', async (request) => {
379
+ await useFastifyUploadContext(request);
380
+ });
381
+
382
+ app.post('/attachments', async () => {
383
+ const validator = Validator.make(
384
+ {},
385
+ {
386
+ attachments: 'files|mimes:png,jpg,pdf',
387
+ },
388
+ );
389
+
390
+ return await validator.validate();
391
+ });
392
+ ```
393
+
394
+ `useFastifyUploadContext()` can normalize uploads from:
395
+
396
+ - `request.files()`
397
+ - `request.parts()`
398
+ - `request.file()`
399
+ - `request.body`
400
+ - `request.raw.files`
401
+
402
+ ### Single validator instance usage
403
+
404
+ ```ts
405
+ import { withFastifyUploadContext } from '@kanun-hq/plugin-file';
406
+
407
+ const validator = await withFastifyUploadContext(
408
+ Validator.make({}, { attachments: 'files|mimes:png,jpg' }),
409
+ request,
410
+ );
411
+
412
+ await validator.validate();
413
+ ```
414
+
415
+ ## Hono
416
+
417
+ ### Middleware-style usage
418
+
419
+ Use `useHonoUploadContext()` to read files from `c.req.parseBody({ all: true })`.
420
+
421
+ ```ts
422
+ import { Hono } from 'hono';
423
+ import { Validator } from 'kanun';
424
+ import {
425
+ fileValidatorPlugin,
426
+ useHonoUploadContext,
427
+ } from '@kanun-hq/plugin-file';
428
+
429
+ Validator.use(fileValidatorPlugin);
430
+
431
+ const app = new Hono();
432
+
433
+ app.use('/profile', async (c, next) => {
434
+ await useHonoUploadContext(c);
435
+ await next();
436
+ });
437
+
438
+ app.post('/profile', async (c) => {
439
+ const validator = Validator.make(
440
+ {},
441
+ {
442
+ avatar: 'file|image|mimetypes:image/png',
443
+ },
444
+ );
445
+
446
+ return c.json(await validator.validate());
447
+ });
448
+ ```
449
+
450
+ ### Single validator instance usage
451
+
452
+ ```ts
453
+ import { withHonoUploadContext } from '@kanun-hq/plugin-file';
454
+
455
+ const validator = await withHonoUploadContext(
456
+ Validator.make({}, { avatar: 'file|image|mimetypes:image/png' }),
457
+ c,
458
+ );
459
+
460
+ await validator.validate();
461
+ ```
462
+
463
+ ## h3
464
+
465
+ ### Middleware-style usage
466
+
467
+ Use `useH3UploadContext()` inside middleware or handlers.
468
+
469
+ ```ts
470
+ import { H3 } from 'h3';
471
+ import { Validator } from 'kanun';
472
+ import { fileValidatorPlugin, useH3UploadContext } from '@kanun-hq/plugin-file';
473
+
474
+ Validator.use(fileValidatorPlugin);
475
+
476
+ const app = new H3();
477
+
478
+ app.use('/profile', async (event, next) => {
479
+ await useH3UploadContext(event);
480
+ return next();
481
+ });
482
+
483
+ app.post('/profile', async () => {
484
+ const validator = Validator.make(
485
+ {},
486
+ {
487
+ avatar: 'file|image|mimetypes:image/png',
488
+ },
489
+ );
490
+
491
+ return await validator.validate();
492
+ });
493
+ ```
494
+
495
+ `useH3UploadContext()` can resolve files from:
496
+
497
+ - `event.context.requestFiles`
498
+ - `event.req.formData()`
499
+ - `event.request.formData()`
500
+ - multipart parsing when available
501
+
502
+ ### Single validator instance usage
503
+
504
+ ```ts
505
+ import { withH3UploadContext } from '@kanun-hq/plugin-file';
506
+
507
+ const validator = await withH3UploadContext(
508
+ Validator.make({}, { avatar: 'file|image|mimetypes:image/png' }),
509
+ event,
510
+ );
511
+
512
+ await validator.validate();
513
+ ```
514
+
515
+ ## Wildcard Multi-file Validation
516
+
517
+ Use `createWildcardFileRules()` when you want both collection-level and item-level rules.
518
+
519
+ ```ts
520
+ import { createWildcardFileRules } from '@kanun-hq/plugin-file';
521
+
522
+ const rules = createWildcardFileRules(
523
+ 'attachments',
524
+ 'file|image|extensions:png,jpg',
525
+ );
526
+ ```
527
+
528
+ This produces rules equivalent to:
529
+
530
+ ```ts
531
+ {
532
+ attachments: ['files'],
533
+ 'attachments.*': ['file', 'image', 'extensions:png,jpg'],
534
+ }
535
+ ```
536
+
537
+ If your uploads live in `requestFiles` rather than `data`, mirror them into the validator data before validation so wildcard expansion can see the collection.
538
+
539
+ ```ts
540
+ import {
541
+ createWildcardFileRules,
542
+ syncRequestFilesToData,
543
+ useExpressUploadContext,
544
+ } from '@kanun-hq/plugin-file';
545
+
546
+ useExpressUploadContext(req);
547
+
548
+ const validator = syncRequestFilesToData(
549
+ Validator.make(
550
+ {},
551
+ createWildcardFileRules(
552
+ 'attachments',
553
+ 'file|image|extensions:png,jpg',
554
+ 'files',
555
+ ),
556
+ ),
557
+ ['attachments'],
558
+ );
559
+
560
+ const validated = await validator.validate();
561
+ ```
562
+
563
+ `validated.attachments` will contain the uploaded files.
564
+
565
+ ## Custom File Resolution
566
+
567
+ If your framework or upload pipeline stores files somewhere other than `context.requestFiles`, provide a custom resolver.
568
+
569
+ ```ts
570
+ import { Validator } from 'kanun';
571
+ import { createFileValidatorPlugin } from '@kanun-hq/plugin-file';
572
+
573
+ Validator.use(
574
+ createFileValidatorPlugin({
575
+ resolveFiles: ({ attribute, context, data, value }) => {
576
+ if (typeof value !== 'undefined') {
577
+ return value;
578
+ }
579
+
580
+ if (context.uploads?.[attribute]) {
581
+ return context.uploads[attribute];
582
+ }
583
+
584
+ return data[`__files.${attribute}`];
585
+ },
586
+ }),
587
+ );
588
+ ```
589
+
590
+ Resolver arguments:
591
+
592
+ - `attribute`: the rule key being validated
593
+ - `context`: the validator context
594
+ - `data`: the validator data object
595
+ - `value`: the current field value from `data`, if present
596
+
597
+ ## Common Patterns
598
+
599
+ ### Validate one avatar upload
600
+
601
+ ```ts
602
+ const validator = Validator.make(
603
+ {},
604
+ {
605
+ avatar: 'file|image|extensions:png,jpg|max:2048',
606
+ },
607
+ ).withContext({
608
+ requestFiles: { avatar },
609
+ });
610
+
611
+ await validator.validate();
612
+ ```
613
+
614
+ ### Validate many attachments
615
+
616
+ ```ts
617
+ const validator = Validator.make(
618
+ { attachments },
619
+ {
620
+ attachments: 'files|mimes:png,jpg,pdf',
621
+ },
622
+ );
623
+
624
+ await validator.validate();
625
+ ```
626
+
627
+ ### Validate an image banner with dimensions
628
+
629
+ ```ts
630
+ const validator = Validator.make(
631
+ {},
632
+ {
633
+ banner:
634
+ 'file|image|mimetypes:image/png|dimensions:min_width=1200,max_width=2400,ratio=3/1',
635
+ },
636
+ ).withContext({
637
+ requestFiles: { banner },
638
+ });
639
+
640
+ await validator.validate();
641
+ ```
642
+
643
+ ### Validate request uploads and return them
644
+
645
+ ```ts
646
+ const validated = await Validator.make(
647
+ {},
648
+ {
649
+ avatar: 'file|image|extensions:png',
650
+ },
651
+ )
652
+ .withContext({
653
+ requestFiles: { avatar },
654
+ })
655
+ .validate();
656
+
657
+ validated.avatar;
658
+ ```
659
+
660
+ ## Notes
661
+
662
+ - Register the plugin once at startup, not per request.
663
+ - `file` expects a single file, while `files` expects an array.
664
+ - `min`, `max`, and `size` use kilobytes for file values.
665
+ - `dimensions` only makes sense for image inputs.
666
+ - Wildcard file validation usually needs `syncRequestFilesToData()` when the source of truth is request context.
667
+
668
+ ## License
669
+
670
+ MIT