fets 0.0.1-alpha-20230302123305-a76a7ea

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/README.md ADDED
@@ -0,0 +1,728 @@
1
+ # FETS
2
+
3
+ [![npm version](https://badge.fury.io/js/fets.svg)](https://badge.fury.io/js/fets)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ FETS is a fully type-safe, web standards compliant, platform independent, and lightweight HTTP
7
+ framework written in TypeScript, it includes a fully type-safe server and OpenAPI client.
8
+
9
+ FETS works with the WHATWG Fetch API that works in any JavaScript environment including Node.js,
10
+ Deno, Bun, Cloudflare Workers, Next.js, Fastify, Express, AWS Lambdas and even in browsers.
11
+
12
+ - FETS Client is a fully type-safe HTTP client that accepts any valid OpenAPI document.
13
+
14
+ - FETS Server is a platform independent HTTP server that can be deployed to any JavaScript
15
+ environment.
16
+
17
+ It doesn't need **ANY CODE GENERATION**.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ yarn add fets
23
+ ```
24
+
25
+ ## Why should I use this package instead of other packages like `itty-router`, `express` or `fastify`?
26
+
27
+ There are many packages out there that allow you to create HTTP routers, but they are not platform
28
+ agnostic. They are only for Node.js or only for those specific environments. But if you use this
29
+ package, your router will work in any environment that uses JavaScript.
30
+
31
+ ## Why should I use this package instead of tRPC?
32
+
33
+ tRPC doesn't need a code generation like FETS, but FETS allows you export an OpenAPI document based
34
+ on the JSON Schema definitions if you don't want to share TypeScript definitions between the client
35
+ and the server. And FETS uses [JSON Schema](https://json-schema.org/) instead of a programmatic
36
+ schema solution like zod which is more portable and has bigger ecosystem. So even if you don't want
37
+ to write JSON Schemas manually, you can use `@sinclair/typebox` to generate them by using an API
38
+ like `zod` has.
39
+
40
+ ## Usage
41
+
42
+ It uses `.route()` method to add routes to the router with the following parameters;
43
+
44
+ - `method`: The HTTP method of the request. This is optional and it handles all methods if it's not
45
+ given.
46
+ - `path`: The URL pattern that the request should match. The url pattern is a string that follows
47
+ the [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) standard. You can
48
+ learn more about URL patterns
49
+ [here](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/URLPattern#matching_a_pathname).
50
+ - `schemas`: An object that contains the schemas for the request and response. This brings a
51
+ type-safety and validation to your requests and responses. You can learn more about schemas
52
+ [here](#end-to-end-type-safety-and-validation-with-json-schema).
53
+ - `handler`: The function that will be called when the request matches the given method and path.
54
+ This function can be either synchronous or asynchronous. If it's asynchronous, it should return a
55
+ `Promise` that resolves to a `Response` object.
56
+
57
+ `Router` gives you an extended version of the regular `Request` object that has the following
58
+ properties:
59
+
60
+ - `request.params`: An object that contains the parameters that are given in the url pattern.
61
+ - `request.query`: An object that contains the query parameters that are given in the url.
62
+
63
+ You can learn more about the original `Request` object
64
+ [here](https://developer.mozilla.org/en-US/docs/Web/API/Request).
65
+
66
+ ### Basic Routing
67
+
68
+ ```ts
69
+ // Then use it in any environment
70
+ import { createServer } from 'http'
71
+ import { createRouter, Response, Router } from 'fets'
72
+
73
+ const router = createRouter()
74
+ // GET collection index
75
+ .route({
76
+ method: 'GET',
77
+ path: '/todos',
78
+ handler: () => new Response('Todos Index!')
79
+ })
80
+ // GET item
81
+ .route({
82
+ method: 'GET',
83
+ path: '/todos/:id',
84
+ handler: ({ params }) => new Response(`Todo #${params.id}`)
85
+ })
86
+ // POST to the collection (we'll use async here)
87
+ .route({
88
+ method: 'POST',
89
+ path: '/todos',
90
+ handler: async request => {
91
+ const content = await request.json()
92
+ return new Response('Creating Todo: ' + JSON.stringify(content))
93
+ }
94
+ })
95
+
96
+ // Redirect to a URL
97
+ .route({
98
+ method: 'GET',
99
+ path: '/google',
100
+ handler: () => Response.redirect('http://www.google.com')
101
+ })
102
+
103
+ // 404 for everything else
104
+ .route({
105
+ path: '*',
106
+ handler: () => new Response('Not Found.', { status: 404 })
107
+ })
108
+
109
+ const httpServer = createServer(router)
110
+ httpServer.listen(4000)
111
+ ```
112
+
113
+ ## Example
114
+
115
+ Let's create a basic REST API that manages users.
116
+
117
+ ```ts
118
+ import { createRouter, Response } from 'fets'
119
+
120
+ const users = [
121
+ { id: '1', name: 'John' },
122
+ { id: '2', name: 'Jane' }
123
+ ]
124
+
125
+ const router = createRouter()
126
+ .route({
127
+ method: 'GET',
128
+ path: '/users',
129
+ handler: () => Response.json(users)
130
+ })
131
+ // Parameters are given in the `request.params` object
132
+ .route({
133
+ method: 'GET',
134
+ path: '/users/:id',
135
+ handler: request => {
136
+ const user = users.find(user => user.id === request.params.id)
137
+
138
+ if (!user) {
139
+ return new Response(null, {
140
+ status: 404
141
+ })
142
+ }
143
+
144
+ return Response.json(user)
145
+ }
146
+ })
147
+ .route({
148
+ method: 'DELETE',
149
+ path: '/users/:id',
150
+ handler: request => {
151
+ const user = users.find(user => user.id === request.params.id)
152
+
153
+ if (!user) {
154
+ return new Response(null, {
155
+ status: 404
156
+ })
157
+ }
158
+
159
+ users.splice(users.indexOf(user), 1)
160
+
161
+ return new Response(null, {
162
+ status: 204
163
+ })
164
+ }
165
+ })
166
+ // Handle JSON bodies
167
+ .route({
168
+ method: 'PUT',
169
+ path: '/users',
170
+ handler: async request => {
171
+ const body = await request.json()
172
+
173
+ const user = {
174
+ id: String(users.length + 1),
175
+ name: body.name
176
+ }
177
+
178
+ users.push(user)
179
+
180
+ return Response.json(user)
181
+ }
182
+ })
183
+ // Handle both parameters and JSON body
184
+ .route({
185
+ method: 'PATCH',
186
+ path: '/users/:id',
187
+ handler: async request => {
188
+ const user = users.find(user => user.id === request.params.id)
189
+
190
+ if (!user) {
191
+ return new Response(null, {
192
+ status: 404
193
+ })
194
+ }
195
+
196
+ const body = await request.json()
197
+
198
+ user.name = body.name
199
+
200
+ return Response.json(user)
201
+ }
202
+ })
203
+ ```
204
+
205
+ ## Middlewares
206
+
207
+ You can also use middlewares to handle requests. Middlewares are functions that are called before
208
+ the request is handled by the router. You can use them to handle authentication, logging, etc.
209
+
210
+ If a handler function doesn't return a `Response` object, the request will be passed to the next
211
+ handler.
212
+
213
+ ```ts
214
+ // In the following example, we are checking if the request has an `Authorization` header.
215
+ const router = createRouter()
216
+ .route({
217
+ path: '*',
218
+ handler: request => {
219
+ if (!request.headers.get('Authorization')) {
220
+ return new Response(null, {
221
+ status: 401
222
+ })
223
+ }
224
+ }
225
+ })
226
+ .route({
227
+ path: '/users',
228
+ method: 'GET',
229
+ handler: request => {
230
+ // It doesn't reach here if the request doesn't have an `Authorization` header.
231
+ });
232
+ ```
233
+
234
+ ### Handler chaining
235
+
236
+ You can also chain multiple handlers to a single route. In the following example, we are checking if
237
+ the request has an `Authorization` header and if the user is an admin.
238
+
239
+ ```ts
240
+ import { RouteHandler } from 'fets'
241
+
242
+ const withAuth: RouteHandler = request => {
243
+ if (!request.headers.get('Authorization')) {
244
+ return new Response(null, {
245
+ status: 401
246
+ })
247
+ }
248
+ }
249
+
250
+ const router = createRouter().route({
251
+ path: '/users',
252
+ method: 'GET',
253
+ handler: [
254
+ withAuth,
255
+ request => {
256
+ // It doesn't reach here if the request doesn't have an `Authorization` header.
257
+ }
258
+ ]
259
+ })
260
+ ```
261
+
262
+ ## Error handling
263
+
264
+ If an unexpected error is thrown, the response will have a `500` status code. You can use the
265
+ `try/catch` method to handle errors. Or you can use the plugins to handle errors like below.
266
+
267
+ ```ts
268
+ import { HTTPError, useErrorHandling } from 'fets'
269
+
270
+ const router = createRouter({
271
+ plugins: [useErrorHandling()]
272
+ }).route({
273
+ path: '/users',
274
+ method: 'GET',
275
+ handler: request => {
276
+ if (!request.headers.get('Authorization')) {
277
+ // You can use `HTTPError` to return a custom error response.
278
+ // It accepts a status code, a message, headers and a body.
279
+ // If you pass a json object as the body, the response will be a json response.
280
+ throw new HTTPError(
281
+ 401,
282
+ 'Unauthorized',
283
+ {
284
+ 'WWW-Authenticate': 'Basic'
285
+ },
286
+ {
287
+ message: 'You need to be authenticated to access this resource.'
288
+ }
289
+ )
290
+ }
291
+ }
292
+ })
293
+ ```
294
+
295
+ ## Plugins to handle CORS, cookies and more
296
+
297
+ FETS also provides a plugin system that allows you hook into the request/response lifecycle.
298
+
299
+ - `onRequest` - Called before the request is handled by the router
300
+ - - It has `endResponse` method that accepts a `Response` object to short-circuit the request
301
+ - `onResponse` - Called after the request is handled by the router
302
+ - - It allows you to modify the response before it is sent to the client
303
+
304
+ ### Cookie Management
305
+
306
+ You can use `useCookies` to parse cookies from the request header and set cookies in the response by
307
+ using Web Standard [CookieStore](https://developer.mozilla.org/en-US/docs/Web/API/CookieStore).
308
+
309
+ ```ts
310
+ import { createRouter, Response, useCookies } from 'fets'
311
+
312
+ const router = createRouter({
313
+ plugins: [useCookies()]
314
+ })
315
+ .route({
316
+ path: '/users',
317
+ method: 'GET',
318
+ handler: request => {
319
+ const sessionId = await request.cookieStore.get('session_id')
320
+ if (!sessionId) {
321
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
322
+ }
323
+ const user = await getUserBySessionId(sessionId)
324
+ return Response.json(user)
325
+ }
326
+ })
327
+ .route({
328
+ path: '/login',
329
+ method: 'POST',
330
+ handler: async request => {
331
+ const { username, password } = await request.json()
332
+ const sessionId = await createSessionForUser({ username, password })
333
+ await request.cookieStore.set('session_id', sessionId)
334
+ return Response.json({ message: 'ok' })
335
+ }
336
+ })
337
+ ```
338
+
339
+ ### CORS Management
340
+
341
+ You can also setup a CORS middleware to handle preflight CORS requests.
342
+
343
+ ```ts
344
+ import { createRouter, useCORS } from 'fets'
345
+
346
+ const router = createRouter({
347
+ plugins: [
348
+ useCORS({
349
+ origin: '*',
350
+ methods: ['GET', 'POST', 'PUT', 'DELETE'],
351
+ headers: ['Content-Type', 'Authorization']
352
+ })
353
+ ]
354
+ })
355
+ ```
356
+
357
+ ### Custom plugins
358
+
359
+ You can also create your own plugins to handle errors, logging, etc.
360
+
361
+ ```ts
362
+ import { createRouter } from 'fets'
363
+
364
+ const useRequestId = (): RoutePlugin => {
365
+ return {
366
+ onRequest({ request, fetchAPI }) {
367
+ let requestId = request.headers.get('X-Request-ID')
368
+ if (!requestId) {
369
+ requestId = fetchAPI.crypto.randomUUID()
370
+ request.headers.set('X-Request-ID', requestId)
371
+ }
372
+ },
373
+ onResponse({ response, fetchAPI }) {
374
+ response.headers.set('X-Request-ID', request.headers.get('X-Request-ID'))
375
+ }
376
+ }
377
+ }
378
+
379
+ const router = createRouter({
380
+ plugins: [useRequestId()]
381
+ })
382
+ ```
383
+
384
+ ## End-to-End Type Safety and Validation with JSON Schema
385
+
386
+ Even if the library provides you some type safety with TypeScript's type inference, you can still
387
+ use JSON Schemas to have a better type safety on both request and response.
388
+
389
+ To define type-safe routes, we use `schemas` parameters
390
+
391
+ ### Typing the request
392
+
393
+ You can type individual parts of the `Request` object including JSON body, headers, query
394
+ parameters, and URL parameters.
395
+
396
+ #### JSON Body
397
+
398
+ ```ts
399
+ import { createRouter, Response } from 'fets'
400
+
401
+ const router = createRouter().route(
402
+ {
403
+ method: 'post',
404
+ path: '/todos',
405
+ // Define the request body schema
406
+ schemas: {
407
+ request: {
408
+ json: {
409
+ type: 'object',
410
+ properties: {
411
+ title: { type: 'string' },
412
+ completed: { type: 'boolean' }
413
+ },
414
+ additionalProperties: false,
415
+ required: ['title']
416
+ }
417
+ }
418
+ }
419
+ },
420
+ async request => {
421
+ // This part is fully typed
422
+ const { title, completed } = await request.json()
423
+ // ...
424
+ return Response.json({ message: 'ok' })
425
+ }
426
+ )
427
+ ```
428
+
429
+ #### Headers
430
+
431
+ ```ts
432
+ import { createRouter, Response } from 'fets'
433
+
434
+ const router = createRouter().route(
435
+ {
436
+ method: 'post',
437
+ path: '/todos',
438
+ // Define the request body schema
439
+ schemas: {
440
+ request: {
441
+ headers: {
442
+ type: 'object',
443
+ properties: {
444
+ 'x-api-key': { type: 'string' }
445
+ },
446
+ additionalProperties: false,
447
+ required: ['x-api-key']
448
+ }
449
+ }
450
+ }
451
+ },
452
+ async request => {
453
+ // This part is fully typed
454
+ const apiKey = request.headers.get('x-api-key')
455
+ // Would fail on TypeScript compilation
456
+ const wrongHeaderName = request.headers.get('x-api-key-wrong')
457
+ // ...
458
+ return Response.json({ message: 'ok' })
459
+ }
460
+ )
461
+ ```
462
+
463
+ #### Path Parameters
464
+
465
+ ```ts
466
+ import { createRouter, Response } from 'fets'
467
+
468
+ const router = createRouter().route(
469
+ {
470
+ method: 'get',
471
+ path: '/todos/:id',
472
+ // Define the request body schema
473
+ schemas: {
474
+ request: {
475
+ params: {
476
+ type: 'object',
477
+ properties: {
478
+ id: { type: 'string' }
479
+ },
480
+ additionalProperties: false,
481
+ required: ['id']
482
+ }
483
+ }
484
+ }
485
+ },
486
+ async request => {
487
+ // This part is fully typed
488
+ const { id } = request.params
489
+ // ...
490
+ return Response.json({ message: 'ok' })
491
+ }
492
+ )
493
+ ```
494
+
495
+ #### Query Parameters
496
+
497
+ ```ts
498
+ import { createRouter, Response } from 'fets'
499
+
500
+ const router = createRouter().addRoute({
501
+ method: 'get',
502
+ path: '/todos',
503
+ // Define the request body schema
504
+ schemas: {
505
+ request: {
506
+ query: {
507
+ type: 'object',
508
+ properties: {
509
+ limit: { type: 'number' },
510
+ offset: { type: 'number' }
511
+ },
512
+ additionalProperties: false,
513
+ required: ['limit']
514
+ },
515
+ }
516
+ }
517
+ }, async request => {
518
+ // This part is fully typed
519
+ const { limit, offset } = request.query
520
+ // You can also use `URLSearchParams` API
521
+ const limit = request.parsedURL.searchParams.get('limit')
522
+ // ...
523
+ return Response.json({ message: 'ok' })
524
+ })
525
+ ```
526
+
527
+ ### Typing the response
528
+
529
+ You can also type the response body by the status code. We strongly recommend to explicitly define
530
+ the status codes.
531
+
532
+ ```ts
533
+ import { createRouter, Response } from 'fets'
534
+
535
+ const router = createRouter().addRoute(
536
+ {
537
+ method: 'get',
538
+ path: '/todos',
539
+ // Define the request body schema
540
+ schemas: {
541
+ request: {
542
+ headers: {
543
+ type: 'object',
544
+ properties: {
545
+ 'x-api-key': { type: 'string' }
546
+ },
547
+ additionalProperties: false,
548
+ required: ['x-api-key']
549
+ }
550
+ },
551
+ response: {
552
+ 200: {
553
+ type: 'array',
554
+ items: {
555
+ type: 'object',
556
+ properties: {
557
+ id: { type: 'string' },
558
+ title: { type: 'string' },
559
+ completed: { type: 'boolean' }
560
+ },
561
+ additionalProperties: false,
562
+ required: ['id', 'title', 'completed']
563
+ }
564
+ },
565
+ 401: {
566
+ type: 'object',
567
+ properties: {
568
+ message: { type: 'string' }
569
+ },
570
+ additionalProperties: false,
571
+ required: ['message']
572
+ }
573
+ }
574
+ }
575
+ },
576
+ async request => {
577
+ const apiKey = request.headers.get('x-api-key')
578
+ if (!apiKey) {
579
+ return Response.json(
580
+ { message: 'API key is required' },
581
+ {
582
+ status: 401
583
+ }
584
+ )
585
+ }
586
+ const todos = await getTodos({
587
+ apiKey
588
+ })
589
+ // This part is fully typed
590
+ return Response.json(todos, {
591
+ status: 200
592
+ })
593
+ }
594
+ )
595
+ ```
596
+
597
+ ### Runtime validation with Ajv
598
+
599
+ The library itself doesn't include a runtime validation by default. But you can use that plugin to
600
+ have a runtime validation with the provided JSON Schemas above. This plugin uses
601
+ [Ajv](https://ajv.js.org/) under the hood. All you have to do is to install `ajv` and `ajv-formats`
602
+ packages and pass `Ajv` to the router.
603
+
604
+ ```ts
605
+ import Ajv from 'ajv'
606
+ import { addFormats } from 'ajv-formats'
607
+ import { createRouter } from 'fets'
608
+
609
+ const ajv = new Ajv()
610
+ // Some type issues with Ajv
611
+ addFormats(ajv as any)
612
+
613
+ const router = createRouter({
614
+ ajv
615
+ })
616
+ ```
617
+
618
+ ### OpenAPI Generation
619
+
620
+ You can generate OpenAPI specification from the defined routes by using OpenAPI plugin. This plugin
621
+ also provides you a [Swagger UI](https://swagger.io/tools/swagger-ui/) to test the API.
622
+
623
+ ```ts
624
+ import { createRouter } from 'fets'
625
+
626
+ const router = createRouter({
627
+ plugins: [
628
+ title: 'Todo List Example',
629
+ description: 'A simple todo list example with fets',
630
+ version: '1.0.0'
631
+ // You can access the Swagger UI at `/docs`
632
+ swaggerUIPath: '/docs',
633
+ // You can download the OpenAPI specification as a JSON file
634
+ oasPath: '/openapi.json'
635
+ ]
636
+ })
637
+ ```
638
+
639
+ ### Using a programmatic JSON Schema builder (`zod`-like API)
640
+
641
+ ```ts
642
+ import { Static, Type } from '@sinclair/typebox'
643
+
644
+ const Todo = Type.Object({
645
+ id: Type.String(),
646
+ title: Type.String(),
647
+ completed: Type.Boolean()
648
+ })
649
+
650
+ type Todo = Static<typeof Todo>
651
+
652
+ const router = createRouter().route({
653
+ path: '/todos',
654
+ schemas: {
655
+ request: {
656
+ body: Todo
657
+ },
658
+ response: {
659
+ 200: Type.Array(Todo)
660
+ }
661
+ }
662
+ })
663
+ ```
664
+
665
+ ### Type-safety on the client side
666
+
667
+ With FETS, you can also type the request and response on the client side. But you have two options;
668
+
669
+ 1. You can infer the types from the router itself.
670
+ 2. You can use OpenAPI specification
671
+
672
+ #### Using the `router`
673
+
674
+ ```ts file=examples/client.ts
675
+ import { createClient } from 'fets'
676
+ // Notice `type` in the import to avoid to import it on runtime
677
+ import type { router } from '../fets'
678
+
679
+ const client = createClient<typeof router>({
680
+ endpoint: 'http://localhost:3000'
681
+ })
682
+
683
+ // Everything below is fully typed
684
+ const response = await client['/todo'].put({
685
+ json: {
686
+ title: 'Buy milk',
687
+ completed: false
688
+ }
689
+ })
690
+ const responseJson = await response.json()
691
+ console.table(responseJson)
692
+ ```
693
+
694
+ #### Using OpenAPI
695
+
696
+ You need to save the OpenAPI document to a code file like below;
697
+
698
+ ```ts
699
+ export default { openapi: '3.0.1' /* ... */ }
700
+ ```
701
+
702
+ Then you need to import the OpenAPI document to the client code;
703
+
704
+ ```ts file=examples/client.ts
705
+ import { createOASClient, Mutable } from 'fets'
706
+ // Notice `type` in the import to avoid to import it on runtime
707
+ import type oas from './saved_openapi'
708
+
709
+ const client = createOASClient<Mutable<typeof oas>>({
710
+ endpoint: 'http://localhost:3000'
711
+ })
712
+
713
+ // Everything below is fully typed
714
+ const response = await client['/todo'].put({
715
+ json: {
716
+ title: 'Buy milk',
717
+ completed: false
718
+ }
719
+ })
720
+ const responseJson = await response.json()
721
+ console.table(responseJson)
722
+ ```
723
+
724
+ ## Usage in environments
725
+
726
+ `Router` is actually an instance of `ServerAdapter` of `@whatwg-node/server` package. So you can use
727
+ it in any environment just like `ServerAdapter`. See the [documentation](../server/README.md) of
728
+ `@whatwg-node/server` package for more information.