├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── cover.png ├── package.json ├── packages ├── clover │ ├── .eslintrc.js │ ├── .gitignore │ ├── .npmignore │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── index.ts │ │ ├── responses.ts │ │ ├── server.ts │ │ ├── test.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── docs │ ├── .gitignore │ ├── CHANGELOG.md │ ├── next.config.js │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── _meta.json │ │ ├── client.mdx │ │ ├── eli5.mdx │ │ ├── index.mdx │ │ └── server.mdx │ ├── public │ │ ├── cover.png │ │ └── eli5 │ │ │ ├── 1_openapi.png │ │ │ ├── 2_more_arrows.png │ │ │ └── 3_full_stack.png │ ├── theme.config.tsx │ └── tsconfig.json └── eslint-config-custom │ ├── index.js │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 16.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16.x 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v2.2.4 25 | 26 | - name: Install Dependencies 27 | run: pnpm i 28 | 29 | - name: Create Release Pull Request or Publish to npm 30 | id: changesets 31 | uses: changesets/action@v1 32 | with: 33 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 34 | publish: pnpm release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | # vercel 36 | .vercel 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#2f7c47", 4 | "activityBar.background": "#2f7c47", 5 | "activityBar.foreground": "#e7e7e7", 6 | "activityBar.inactiveForeground": "#e7e7e799", 7 | "activityBarBadge.background": "#422c74", 8 | "activityBarBadge.foreground": "#e7e7e7", 9 | "commandCenter.border": "#e7e7e799", 10 | "sash.hoverBorder": "#2f7c47", 11 | "statusBar.background": "#215732", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#2f7c47", 14 | "statusBarItem.remoteBackground": "#215732", 15 | "statusBarItem.remoteForeground": "#e7e7e7", 16 | "titleBar.activeBackground": "#215732", 17 | "titleBar.activeForeground": "#e7e7e7", 18 | "titleBar.inactiveBackground": "#21573299", 19 | "titleBar.inactiveForeground": "#e7e7e799" 20 | }, 21 | "peacock.color": "#215732" 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sarim Abbas 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clover 2 | 3 | Server routes augmented with Zod and OpenAPI 4 | 5 | ![](./assets/cover.png) 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pnpm add @sarim.garden/clover 11 | ``` 12 | 13 | Or use `npm` or `yarn`. 14 | 15 | ## Docs 16 | 17 | You can find the latest documentation on https://clover.lil.run 18 | 19 | -------------------------------------------------------------------------------- /assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/assets/cover.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "turbo run build", 5 | "dev": "turbo run dev", 6 | "lint": "turbo run lint", 7 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 8 | "release": "turbo run build lint && changeset version && changeset publish" 9 | }, 10 | "devDependencies": { 11 | "eslint": "^7.32.0", 12 | "prettier": "^2.5.1", 13 | "@changesets/cli": "^2.26.1", 14 | "turbo": "latest" 15 | }, 16 | "packageManager": "pnpm@7.15.0", 17 | "name": "cardboard", 18 | "dependencies": {} 19 | } 20 | -------------------------------------------------------------------------------- /packages/clover/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/clover/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/clover/.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/packages/clover/.npmignore -------------------------------------------------------------------------------- /packages/clover/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sarim.garden/clover 2 | 3 | ## 2.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 5d52e2a: Add more documentation 8 | 9 | ## 2.0.0 10 | 11 | ### Major Changes 12 | 13 | - 840789b: `makeRequestHandler` now exports `clientConfig` instead of `clientTypes` for clarity 14 | 15 | ## 1.0.0 16 | 17 | ### Major Changes 18 | 19 | - 203f97a: Release initial versions of packages 20 | -------------------------------------------------------------------------------- /packages/clover/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sarim.garden/clover", 3 | "version": "2.0.1", 4 | "description": "Server routes enhanced with Zod and OpenAPI schemas", 5 | "type": "module", 6 | "exports": { 7 | "types": "./dist/index.d.ts", 8 | "import": "./dist/index.js" 9 | }, 10 | "typesVersions": { 11 | "*": { 12 | "*": [ 13 | "./dist/*" 14 | ] 15 | } 16 | }, 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "scripts": { 21 | "build": "tsup --config ./tsup.config.ts", 22 | "dev": "pnpm run build --watch", 23 | "prepublishOnly": "pnpm run build" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "MIT", 28 | "dependencies": { 29 | "@anatine/zod-openapi": "^1.12.1", 30 | "lodash.merge": "^4.6.2", 31 | "path-to-regexp": "^6.2.1", 32 | "zod": "^3.21.4", 33 | "openapi3-ts": "^4.1.2" 34 | }, 35 | "devDependencies": { 36 | "@types/lodash.merge": "^4.6.7", 37 | "eslint": "^8.40.0", 38 | "eslint-config-custom": "workspace:*", 39 | "tsup": "^6.7.0", 40 | "typescript": "^5.0.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/clover/src/client.ts: -------------------------------------------------------------------------------- 1 | import { compile } from "path-to-regexp"; 2 | import { z } from "zod"; 3 | import type { IClientConfig } from "./server"; 4 | import { HTTPMethod, httpMethodSupportsRequestBody } from "./utils"; 5 | 6 | export interface IMakeFetcherProps { 7 | /** 8 | * the base URL of the server 9 | */ 10 | baseUrl: string; 11 | /** 12 | * headers to send with every request 13 | */ 14 | headers?: Headers; 15 | } 16 | 17 | /** 18 | * 19 | * @param outerProps - the props to configure the fetcher 20 | * @returns a function that can be used to make requests to the server 21 | */ 22 | export const makeFetcher = (outerProps: IMakeFetcherProps) => { 23 | /** 24 | * 25 | * @param props - the props to make the request 26 | * @returns the response from the server 27 | */ 28 | const fetcher = async < 29 | TConfig extends IClientConfig< 30 | z.AnyZodObject, 31 | z.AnyZodObject, 32 | HTTPMethod, 33 | string 34 | > 35 | >( 36 | props: Pick & { 37 | validator?: TConfig["output"]; 38 | } 39 | ): Promise> => { 40 | // substitute any path params using the input 41 | const pathSubstitutor = compile(props.path); 42 | const substitutedPath = pathSubstitutor(props.input); 43 | 44 | // create a ful url to the endpoint 45 | const url = new URL(substitutedPath, outerProps.baseUrl); 46 | 47 | const resp = await fetch( 48 | // if the method supports a request body, send as JSON 49 | // otherwise, send as query params 50 | httpMethodSupportsRequestBody[props.method] 51 | ? url 52 | : new URL(url.toString() + "?" + new URLSearchParams(props.input)), 53 | { 54 | method: props.method, 55 | headers: { 56 | ...(httpMethodSupportsRequestBody[props.method] 57 | ? { "Content-Type": "application/json" } 58 | : {}), 59 | ...(outerProps.headers 60 | ? Object.fromEntries(outerProps.headers.entries()) 61 | : {}), 62 | }, 63 | body: httpMethodSupportsRequestBody[props.method] 64 | ? JSON.stringify(props.input) 65 | : undefined, 66 | } 67 | ); 68 | 69 | const output = await resp.json(); 70 | 71 | if (props.validator) { 72 | props.validator.parse(output); 73 | } 74 | 75 | return output; 76 | }; 77 | 78 | return fetcher; 79 | }; 80 | -------------------------------------------------------------------------------- /packages/clover/src/index.ts: -------------------------------------------------------------------------------- 1 | export { makeFetcher, type IMakeFetcherProps } from "./client"; 2 | export { 3 | makeRequestHandler, 4 | type IMakeRequestHandlerProps, 5 | type IMakeRequestHandlerReturn, 6 | } from "./server"; 7 | export type { 8 | OpenAPIObject, 9 | OpenAPIPathsObject, 10 | OpenAPIPathItemObject, 11 | } from "./utils"; 12 | -------------------------------------------------------------------------------- /packages/clover/src/responses.ts: -------------------------------------------------------------------------------- 1 | import { oas31 } from "openapi3-ts"; 2 | 3 | export const commonReponses = { 4 | 405: { 5 | openAPISchema: { 6 | description: "Method not allowed", 7 | content: { 8 | "application/json": { 9 | schema: { 10 | type: "object", 11 | properties: { 12 | error: { 13 | type: "string", 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | response: () => 21 | new Response(JSON.stringify({ error: "Method not allowed" }), { 22 | status: 405, 23 | }), 24 | }, 25 | 400: { 26 | openAPISchema: { 27 | description: "Bad request", 28 | content: { 29 | "application/json": { 30 | schema: { 31 | type: "object", 32 | properties: { 33 | error: { 34 | type: "string", 35 | }, 36 | details: { 37 | type: "object", 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | response: (details: Record) => 45 | new Response(JSON.stringify({ error: "Bad request", details }), { 46 | status: 400, 47 | }), 48 | }, 49 | 401: { 50 | openAPISchema: { 51 | description: "Unauthorized", 52 | }, 53 | response: () => new Response(null, { status: 401 }), 54 | }, 55 | } satisfies Record< 56 | string, 57 | { 58 | openAPISchema: oas31.ResponseObject; 59 | response: (props: any) => Response; 60 | } 61 | >; 62 | -------------------------------------------------------------------------------- /packages/clover/src/server.ts: -------------------------------------------------------------------------------- 1 | import { generateSchema } from "@anatine/zod-openapi"; 2 | import merge from "lodash.merge"; 3 | import { oas31 } from "openapi3-ts"; 4 | import { z } from "zod"; 5 | import { commonReponses } from "./responses"; 6 | import { 7 | HTTPMethod, 8 | getKeysFromPathPattern, 9 | getParamsFromPath, 10 | httpMethodSupportsRequestBody, 11 | } from "./utils"; 12 | 13 | export interface IMakeRequestHandlerProps< 14 | TInput extends z.AnyZodObject, 15 | TOutput extends z.AnyZodObject, 16 | TMethod extends HTTPMethod, 17 | TPath extends string 18 | > { 19 | /** 20 | * describe the shape of the input 21 | */ 22 | input: TInput; 23 | /** 24 | * describe the shape of the output 25 | */ 26 | output: TOutput; 27 | /** 28 | * specify the HTTP method 29 | */ 30 | method: TMethod; 31 | /** 32 | * specify the path 33 | */ 34 | path: TPath; 35 | /** 36 | * optional description 37 | */ 38 | description?: string; 39 | /** 40 | * the presence of this property will make the route require bearer authentication 41 | * @param request - the request, do whatever you want with it 42 | * @returns - if false, the request will be rejected 43 | */ 44 | authenticate?: (request: Request) => Promise; 45 | /** 46 | * a callback inside which you can run your logic 47 | * @returns a response to send back to the client 48 | */ 49 | run: ({ 50 | request, 51 | input, 52 | sendOutput, 53 | }: { 54 | /** 55 | * the raw request, do whatever you want with it 56 | */ 57 | request: Request; 58 | /** 59 | * a helper with the input data 60 | */ 61 | input: z.infer; 62 | /** 63 | * @param output - the output data 64 | * @returns a helper to send the output 65 | */ 66 | sendOutput: ( 67 | output: z.infer, 68 | options?: Partial 69 | ) => Promise; 70 | }) => Promise; 71 | } 72 | 73 | export interface IClientConfig< 74 | TInput extends z.AnyZodObject, 75 | TOutput extends z.AnyZodObject, 76 | TMethod extends HTTPMethod, 77 | TPath extends string 78 | > { 79 | /** 80 | * the typescript types for the input 81 | * exclude the path parameters that are automatically added 82 | */ 83 | // input: HumanReadable, PathParamNames>>; 84 | input: z.infer; 85 | /** 86 | * the zod schema for the output 87 | */ 88 | output: TOutput; 89 | /** 90 | * the HTTP method 91 | */ 92 | method: TMethod; 93 | /** 94 | * the path the route is available on 95 | */ 96 | path: TPath; 97 | } 98 | 99 | export interface IMakeRequestHandlerReturn< 100 | TInput extends z.AnyZodObject, 101 | TOutput extends z.AnyZodObject, 102 | TMethod extends HTTPMethod, 103 | TPath extends string 104 | > { 105 | /** 106 | * config object used to generate typescript types 107 | */ 108 | clientConfig: IClientConfig; 109 | /** 110 | * OpenAPI schema for this route 111 | */ 112 | openAPIPathsObject: oas31.PathsObject; 113 | /** 114 | * @returns WinterCG compatible handler that you can use in your routes 115 | */ 116 | handler: (request: Request) => Promise; 117 | } 118 | 119 | export const makeRequestHandler = < 120 | TInput extends z.AnyZodObject, 121 | TOutput extends z.AnyZodObject, 122 | TMethod extends HTTPMethod, 123 | TPath extends string 124 | >( 125 | props: IMakeRequestHandlerProps 126 | ): IMakeRequestHandlerReturn => { 127 | const openAPIParameters: (oas31.ParameterObject | oas31.ReferenceObject)[] = [ 128 | // query parameters 129 | ...(!httpMethodSupportsRequestBody[props.method] 130 | ? Object.keys(props.input.shape) 131 | // exclude query parameters that are already path parameters 132 | .filter((key) => { 133 | return !getKeysFromPathPattern(props.path).some( 134 | (k) => String(k.name) === key 135 | ); 136 | }) 137 | .map((key) => { 138 | return { 139 | name: key, 140 | in: "query" as oas31.ParameterLocation, 141 | schema: { 142 | type: "string" as oas31.SchemaObjectType, 143 | }, 144 | }; 145 | }) 146 | : []), 147 | // add path parameters 148 | ...getKeysFromPathPattern(props.path).map((key) => ({ 149 | name: String(key.name), 150 | in: "path" as oas31.ParameterLocation, 151 | schema: { 152 | type: "string" as oas31.SchemaObjectType, 153 | }, 154 | })), 155 | ]; 156 | 157 | const openAPIRequestBody: 158 | | oas31.ReferenceObject 159 | | oas31.RequestBodyObject 160 | | undefined = httpMethodSupportsRequestBody[props.method] 161 | ? { 162 | content: { 163 | "application/json": { 164 | schema: generateSchema(props.input), 165 | }, 166 | }, 167 | } 168 | : undefined; 169 | 170 | const openAPIOperation: oas31.OperationObject = { 171 | description: props.description, 172 | security: props.authenticate ? [{ bearerAuth: [] }] : undefined, 173 | parameters: openAPIParameters, 174 | requestBody: openAPIRequestBody, 175 | responses: { 176 | // success 177 | 200: { 178 | description: "Success", 179 | content: { 180 | "application/json": { 181 | schema: generateSchema(props.output), 182 | }, 183 | }, 184 | }, 185 | // bad request 186 | 400: commonReponses[400].openAPISchema, 187 | // unauthorized 188 | 401: props.authenticate ? commonReponses[401].openAPISchema : undefined, 189 | // sarim: i don't think we need this 190 | // 405: commonReponses[405].openAPISchema, 191 | }, 192 | }; 193 | 194 | const openAPIPathItem: oas31.PathItemObject = { 195 | [props.method.toLowerCase()]: openAPIOperation, 196 | }; 197 | 198 | const openAPIPath: oas31.PathsObject = { 199 | [props.path]: openAPIPathItem, 200 | }; 201 | 202 | const handler = async (request: Request) => { 203 | const requestForRun = request.clone(); 204 | const requestForAuth = request.clone(); 205 | 206 | // ensure the method is correct 207 | if (request.method !== props.method) { 208 | return commonReponses[405].response(); 209 | } 210 | 211 | // ensure authentication is correct 212 | if (props.authenticate && !(await props.authenticate(requestForAuth))) { 213 | return commonReponses[401].response(); 214 | } 215 | 216 | // parse the input 217 | const unsafeData = { 218 | // parse input from path parameters 219 | ...getParamsFromPath(props.path, new URL(request.url).pathname), 220 | // parse input from query parameters or body 221 | ...(httpMethodSupportsRequestBody[request.method as HTTPMethod] 222 | ? // if the method supports a body, parse it 223 | await request.json() 224 | : // otherwise, parse the query parameters 225 | Object.fromEntries(new URL(request.url).searchParams.entries())), 226 | }; 227 | 228 | // parse the input with zod schema 229 | const parsedData = await props.input.safeParseAsync(unsafeData); 230 | 231 | // if the input is invalid, return a 400 232 | if (!parsedData.success) { 233 | return commonReponses[400].response(parsedData.error); 234 | } 235 | 236 | const input = parsedData.data; 237 | 238 | // utility function to send output response 239 | const sendOutput = async ( 240 | output: z.infer, 241 | options?: Partial 242 | ) => { 243 | return new Response( 244 | JSON.stringify(output), 245 | merge( 246 | { 247 | status: 200, 248 | headers: { 249 | "Content-Type": "application/json", 250 | }, 251 | }, 252 | options 253 | ) 254 | ); 255 | }; 256 | 257 | // run the user's code 258 | return props.run({ request: requestForRun, input, sendOutput }); 259 | }; 260 | 261 | return { 262 | clientConfig: { 263 | input: {} as any, // implementation does not matter, we just need the types 264 | output: props.output, // echo the zod schema 265 | method: props.method, 266 | path: props.path, 267 | }, 268 | openAPIPathsObject: openAPIPath, 269 | handler, 270 | }; 271 | }; 272 | -------------------------------------------------------------------------------- /packages/clover/src/test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { makeFetcher } from "./client"; 3 | import { makeRequestHandler } from "./server"; 4 | 5 | const { handler, clientConfig, openAPIPathsObject } = makeRequestHandler({ 6 | input: z.object({ 7 | name: z.string(), 8 | }), 9 | output: z.object({ 10 | greeting: z.string(), 11 | }), 12 | run: async ({ request, input, sendOutput }) => { 13 | const { name } = input; 14 | return sendOutput({ greeting: `Hello, ${name}!` }); 15 | }, 16 | path: "/api/hello", 17 | method: "GET", 18 | description: "Greets the user", 19 | authenticate: async (req) => { 20 | return true; 21 | }, 22 | }); 23 | 24 | const getTest = makeFetcher({ 25 | baseUrl: "http://localhost:3000", 26 | }); 27 | 28 | const resp = getTest({ 29 | input: { 30 | name: "test", 31 | }, 32 | method: "GET", 33 | path: "/api/hello", 34 | validator: z.object({ 35 | greeting: z.string(), 36 | }), 37 | }); 38 | -------------------------------------------------------------------------------- /packages/clover/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { oas31 } from "openapi3-ts"; 2 | import { Key, Path, match, pathToRegexp } from "path-to-regexp"; 3 | 4 | export type OpenAPIObject = oas31.OpenAPIObject; 5 | export type OpenAPIPathsObject = oas31.PathsObject; 6 | export type OpenAPIPathItemObject = oas31.PathItemObject; 7 | 8 | export type HumanReadable = { 9 | [K in keyof T]: T[K]; 10 | } & {}; 11 | 12 | export type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; 13 | 14 | export const httpMethodSupportsRequestBody: Record = { 15 | GET: false, 16 | POST: true, 17 | PUT: true, 18 | PATCH: true, 19 | DELETE: false, 20 | }; 21 | 22 | export const getKeysFromPathPattern = (pattern: Path): Key[] => { 23 | const keys: Key[] = []; 24 | pathToRegexp(pattern, keys); 25 | return keys; 26 | }; 27 | 28 | export const getParamsFromPath = ( 29 | pattern: string, 30 | input: string 31 | ): Record => { 32 | const matcher = match(pattern, { decode: decodeURIComponent }); 33 | const result = matcher(input); 34 | if (!result) { 35 | return {}; 36 | } 37 | return result.params; 38 | }; 39 | 40 | /** 41 | * get all parameters from an API path 42 | * thanks to Zodios for this snippet 43 | * @param Path - API path 44 | * @details - this is using tail recursion type optimization from typescript 4.5 45 | */ 46 | export type PathParamNames< 47 | Path, 48 | Acc = never 49 | > = Path extends `${string}:${infer Name}/${infer R}` 50 | ? PathParamNames 51 | : Path extends `${string}:${infer Name}` 52 | ? Name | Acc 53 | : Acc; 54 | -------------------------------------------------------------------------------- /packages/clover/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "target": "es2016", 5 | "lib": ["es2019", "dom", "DOM.Iterable"], 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | // Generate d.ts files 12 | "declaration": true, 13 | // This compiler run should 14 | // only output d.ts files 15 | "emitDeclarationOnly": true, 16 | // Types should go into this directory. 17 | // Removing this would place the .d.ts files 18 | // next to the .js files 19 | "outDir": "dist", 20 | // go to js file when using IDE functions like 21 | // "Go to Definition" in VSCode 22 | "declarationMap": true 23 | }, 24 | "include": ["src/**/*"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/clover/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | dts: true, 6 | entry: ["src/index.ts"], 7 | format: ["esm"], 8 | }); 9 | -------------------------------------------------------------------------------- /packages/docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /packages/docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sarim.garden/clover-docs 2 | 3 | ## 1.2.0 4 | 5 | ### Minor Changes 6 | 7 | - 5d52e2a: Add more documentation 8 | 9 | ## 1.1.0 10 | 11 | ### Minor Changes 12 | 13 | - 840789b: `makeRequestHandler` now exports `clientConfig` instead of `clientTypes` for clarity 14 | 15 | ## 1.0.0 16 | 17 | ### Major Changes 18 | 19 | - 203f97a: Release initial versions of packages 20 | -------------------------------------------------------------------------------- /packages/docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require("nextra")({ 2 | theme: "nextra-theme-docs", 3 | themeConfig: "./theme.config.tsx", 4 | }); 5 | 6 | module.exports = withNextra(); 7 | -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sarim.garden/clover-docs", 3 | "version": "1.2.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "18.16.3", 13 | "@types/react": "18.2.4", 14 | "@types/react-dom": "18.2.3", 15 | "typescript": "5.0.4" 16 | }, 17 | "dependencies": { 18 | "@vercel/analytics": "^1.0.1", 19 | "next": "13.3.4", 20 | "nextra": "^2.5.1", 21 | "nextra-theme-docs": "^2.5.1", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { Analytics } from "@vercel/analytics/react"; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /packages/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Getting started", 3 | "server": "Server", 4 | "client": "Client", 5 | "eli5": "ELI5" 6 | } 7 | -------------------------------------------------------------------------------- /packages/docs/pages/client.mdx: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | Since Clover routes are OpenAPI compliant, you can generate a client against the spec using any library of your choice e.g. [Orval](https://orval.dev/overview) or [oazapfts](https://github.com/oazapfts/oazapfts). 4 | 5 | If you'd rather not add a client generation step, Clover also provides a lightweight fetcher that can be used to make requests to the API. 6 | 7 | ```ts 8 | // server.ts 9 | 10 | import { makeRequestHandler } from "@sarim.garden/clover"; 11 | 12 | const { handler, clientConfig, openAPIPathsObject } = makeRequestHandler({ 13 | input: z.object({ 14 | name: z.string(), 15 | }), 16 | output: z.object({ 17 | greeting: z.string(), 18 | }), 19 | run: async ({ request, input, sendOutput }) => { 20 | const { name } = input; 21 | return sendOutput({ greeting: `Hello, ${name}!` }); 22 | }, 23 | path: "/api/hello", 24 | method: "GET", 25 | description: "Greets the user", 26 | authenticate: async (req) => { 27 | return true; 28 | }, 29 | }); 30 | 31 | export type clientTypes = typeof clientConfig; 32 | ``` 33 | 34 | ```ts 35 | // client.ts 36 | 37 | import { makeFetcher } from "@sarim.garden/clover"; 38 | 39 | export const fetcher = makeFetcher({ 40 | baseUrl: "https://api.example.com", 41 | headers: {}, 42 | }); 43 | ``` 44 | 45 | The fetcher is simply a typesafe wrapper around fetch that manages query params, path params, and request/response bodies. You can use the fetcher anywhere to make network requests to your API routes. The fetcher is generic; once you pass in the client types, you will get intellisense on all the fields. 46 | 47 | ```ts 48 | // some/other/file.ts 49 | 50 | import { fetcher } from "../../client"; 51 | import type { clientTypes } from "../../server"; 52 | 53 | const resp = fetcher({ 54 | input: { 55 | name: "Sarim", 56 | }, 57 | method: "GET", 58 | path: "/api/hello", 59 | }); 60 | ``` 61 | 62 | ## `makeFetcher` 63 | 64 | ### Props 65 | 66 | | Name | Type | Description | 67 | | :-------- | :-------- | :------------------------------------------------------------------- | 68 | | `baseUrl` | `string` | Where your API routes can be reached e.g. `https://api.example.com`. | 69 | | `headers` | `Headers` | Common headers sent with each request. | 70 | 71 | ## `fetcher` 72 | 73 | ### Props 74 | 75 | | Name | Type | Description | 76 | | :----------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------ | 77 | | `input` | `z.infer` | Input variables as described by your server route input schema. | 78 | | `path` | `string` | The relative path where your handler can be reached e.g. `/api/hello-world`. This will be constrained so there can only be one value. | 79 | | `method` | `HTTPMethod` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. This will be constrained so there can only be one value. | 80 | | `validator?` | `AnyZodObject` | Parse the received response with a Zod schema. It must be the same as the server output schema or you will get a type error. | 81 | -------------------------------------------------------------------------------- /packages/docs/pages/eli5.mdx: -------------------------------------------------------------------------------- 1 | # Explain like I'm five 2 | 3 | Lately, I’ve been contemplating the inaccessibility of modern frontend development and striving to enhance my projects’ documentation by including an ELI5 section. My aim is to provide detailed explanations while being respectful of your time. I'll try to assume very little knowledge! If you already know this stuff, feel free to skip this section. If this is helpful, please let me know! 4 | 5 | ## OpenAPIs 6 | 7 | First, let's talk about APIs. APIs help machines/servers communicate. The most common API conventions are REST or GraphQL. They both work over HTTP. GraphQL is nice because it is a strongly typed schema with its own query language. Meanwhile REST does not enforce any schema conventions. 8 | 9 | If you want to add schema validation to your REST API, you can make it OpenAPI compliant. OpenAPI is a specification for describing REST APIs. It is a JSON schema that describes the structure of your API. An OpenAPI schema can help other humans and machines understand how your API works. They can generate client code against your API, or generate test cases, or configure infrastructure, etc. 10 | 11 | For example, OpenAI's [ChatGPT plugin system](https://platform.openai.com/docs/plugins/introduction) relies on OpenAPIs. A "ChatGPT plugin" is just a pointer to an OpenAPI specification. Then ChatGPT will figure out how to use it from that specification alone 🙀. (Side note: it's unfortunate how similar both the words "OpenAI" and "OpenAPI" are 😅) 12 | 13 | ![](/eli5/1_openapi.png) 14 | 15 | ### Client generation 16 | 17 | If an API is OpenAPI compliant, you can generate client-code against it. This is a huge time-saver. You don't have to manually write the HTTP requests and responses. You can just import the client and call the functions. The client is type-safe, so you can't make typos in the API calls. 18 | 19 | Here is an example of querying against an API which isn't OpenAPI compliant. 20 | 21 | ```ts 22 | // someone else's API endpoint, available on 23 | // https://example.com/api 24 | 25 | const handler = (request: Request) => { 26 | const name = new URL(request.url).searchParams.get("name"); 27 | return new Response(JSON.stringify({ greeting: `Hello, ${name}!` }), { 28 | headers: { "content-type": "application/json" }, 29 | }); 30 | }; 31 | ``` 32 | 33 | ```ts 34 | // how you would query against it in your code 35 | 36 | const response = await fetch( 37 | `https://example.com/api?name=${encodeURIComponent("Hedwig")}` 38 | ); 39 | const data: { 40 | greeting: string; 41 | } = await response.json(); 42 | ``` 43 | 44 | This feels a bit brittle. You have to make sure you are passing the right query params, and that the response is what you expect. If you make a typo, you won't know until runtime. 45 | 46 | Here is an example of querying against an API which is OpenAPI compliant: 47 | 48 | ```json 49 | // https://petstore3.swagger.io/api/v3/openapi.json 50 | { 51 | "openapi": "3.0.2", 52 | "info": { 53 | "title": "Petstore - OpenAPI 3.0", 54 | "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification.", 55 | "version": "1.0.17" 56 | }, 57 | "servers": [ 58 | { 59 | "url": "/api/v3" 60 | } 61 | ], 62 | "tags": [ 63 | { 64 | "name": "pet", 65 | "description": "Everything about your Pets" 66 | } 67 | ], 68 | "paths": { 69 | "/pet/findByStatus": { 70 | "get": { 71 | "tags": ["pet"], 72 | "summary": "Finds Pets by status", 73 | "description": "Multiple status values can be provided with comma separated strings", 74 | "parameters": [ 75 | { 76 | "name": "status", 77 | "in": "query", 78 | "description": "Status values that need to be considered for filter", 79 | "required": false, 80 | "explode": true, 81 | "schema": { 82 | "type": "string", 83 | "default": "available", 84 | "enum": ["available", "pending", "sold"] 85 | } 86 | } 87 | ], 88 | "responses": { 89 | "200": { 90 | "description": "successful operation", 91 | "content": { 92 | "application/json": { 93 | "schema": { 94 | "type": "array", 95 | "items": { 96 | "$ref": "#/components/schemas/Pet" 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | We can generate a type-safe client against it using a library like [Orval](https://orval.dev/overview) or [oazapfts](https://github.com/oazapfts/oazapfts). These libraries wrap `fetch`/`axios` and do the input/output parsing in the native typesystem of the language, e.g. TypeScript, Go, Rust etc. Some pseudocode: 110 | 111 | ```bash 112 | codegen --input https://petstore3.swagger.io/api/v3/openapi.json --output ./src/client.ts 113 | ``` 114 | 115 | And now we can use the client in our code. Notice how it is type-safe, and we neither have to worry about typos, nor about how to prepare the underlying `fetch` call. 116 | 117 | ```ts 118 | import { findPetsByStatus } from "./src/client"; 119 | 120 | // find pet by status 121 | const response = await findPetsByStatus({ 122 | status: ["available", "pending"], 123 | }); 124 | ``` 125 | 126 | ### Building an OpenAPI compliant API 127 | 128 | In the above section, I showed an OpenAPI spec, but didn't show how the server code was implemented. It's nothing fancy, you as the implementer just have to make sure you follow the contract: 129 | 130 | ```ts 131 | // this handler should be reachable on /api/v3/pet/findByStatus 132 | const handler = (request: Request) => { 133 | const status = new URL(request.url).searchParams.getAll("status"); 134 | const pets = db.getPets({ 135 | filter: { 136 | status: status, 137 | }, 138 | }); 139 | return new Response(JSON.stringify(pets), { 140 | headers: { "content-type": "application/json" }, 141 | }); 142 | }; 143 | ``` 144 | 145 | This also feels a bit brittle. If we make a typo, or return the wrong data, we won't know until runtime that we're not respecting the spec. If we make changes to the spec, we have to make sure we update the handler to match (and vice versa). 146 | 147 | ### Automated OpenAPI generation 148 | 149 | ![](/eli5/2_more_arrows.png) 150 | 151 | Instead of a schema-first approach, what if we could just write our server code first? And then generate the OpenAPI spec from the server code? This would be a huge time-saver. We wouldn't have to write the OpenAPI spec by hand, and we wouldn't have to worry about keeping the spec and the server code in sync. With the spec, we can generate a client, and use that anywhere. The types "flow" across the stack. 152 | 153 | Here is an [example from `tsoa`](https://tsoa-community.github.io/docs/getting-started.html), a popular server framework for this purpose: 154 | 155 | ```ts 156 | import { Controller, Get, Query } from "tsoa"; 157 | import { Pet } from "../models/Pet"; 158 | 159 | @Route("pet") 160 | export class PetController extends Controller { 161 | @Get("findByStatus") 162 | public async findByStatus(@Query() status?: string): Promise { 163 | // Your implementation here 164 | return []; 165 | } 166 | } 167 | ``` 168 | 169 | `tsoa` will look at those decorators and generate an OpenAPI spec for you. 170 | 171 | ## Full stack frameworks 172 | 173 | ### Types of server code 174 | 175 | The next piece of the puzzle is to see where OpenAPI compliant server code can live inside full stack frameworks. Some common frameworks include: Next.js, Remix, Nuxt, SolidStart, SvelteKit, Astro and many more. These frameworks blur the boundary between client and server (these days, even more so than when they first started). Here are some ways they blur the boundary: 176 | 177 | 1. Nearly all of them let you write server code inside dedicated files. These are request/response handlers, and are usually written in the style of the underlying runtime (e.g. Node Express, or Edge). For example, in Next.js, you could write API routes like this: 178 | 179 | ```ts 180 | // server code in the express style 181 | // pages/api/hello.ts 182 | // e.g. https://nextjs.org/docs/pages/building-your-application/routing/api-routes 183 | const handler = (req, res) => { 184 | return res.json({}); 185 | }; 186 | 187 | // OR in the winterCG style 188 | // app/hello/route.ts 189 | // e.g. https://nextjs.org/docs/app/building-your-application/routing/router-handlers 190 | const handler = (request: Request) => { 191 | return new Response(); 192 | }; 193 | ``` 194 | 195 | 2. They let you run server code inside dedicated lifecycle hooks. For example, in Next.js, you could run server code inside `getServerSideProps`. The code runs each time the page is loaded, and the data is passed to the client. This is a bit different from the previous example, because the server code is not a request/response handler, but a data-fetching hook. You couldn't really use this to mutate data from the client. For example: 196 | 197 | ```ts 198 | // e.g. https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props 199 | export const getServerSideProps = () => { 200 | return { 201 | props: {}, 202 | }; 203 | }; 204 | ``` 205 | 206 | 3. Other abstractions. Remix let's you have [specially named `loader` functions](https://remix.run/docs/en/main/route/loader) that fetch data. Next.js supports [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering#rendering-environments) that can run top-level `async/await` network fetches on the server. There is some magic behind the scenes to make this work. My best guess is that the server bits are extracted by a compiler and used to spin up Lambdas/Edge functions. This blurring of the boundary has its pitfalls, you can see [this talk by Rich Harris](https://www.youtube.com/watch?v=uXCipjbcQfM&pp=ygULcmljaCBoYXJyaXM%3D) (creator of SvelteKit) to learn more. 207 | 208 | ![](/eli5/3_full_stack.png) 209 | 210 | ### Using Zodios or TRPC to write OpenAPI compliant routes 211 | 212 | I would be remiss if I didn't mention [Zodios](https://www.zodios.org/docs/server/next). You can give it control of a wildcard route pattern in your framework, and write your server code using its primitives. It will then: 213 | 214 | 1. Give you a typesafe client for your internal usecase with no generation step, by inferring the types from your server code 215 | 2. Generate an OpenAPI spec from your server code 216 | 3. Let others generate a typesafe client using the OpenAPI spec 217 | 218 | 221 | 222 | Another popular library that does the above is [TRPC](https://github.com/trpc/trpc). It [provides an OpenAPI plugin](https://github.com/jlalmes/trpc-openapi) as well. 223 | 224 | 230 | 231 | ### Using Clover to write OpenAPI compliant routes 232 | 233 | Zodios and TRPC require you to embrace a large set of abstractions. Clover is more lightweight. Here is what an augmented server route might look like: 234 | 235 | ```ts 236 | // app/api/pets/findByStatus/route.ts 237 | import { makeRequestHandler } from "@sarim.garden/clover"; 238 | import { z } from "zod"; 239 | 240 | export const { handler } = makeRequestHandler({ 241 | method: "GET", 242 | path: "/api/pets/findByStatus", 243 | description: "Finds Pets by status", 244 | input: z.object({ 245 | status: z.enum(["available", "pending", "sold"]).array().optional(), 246 | }), 247 | output: z.object({ 248 | // ...pet schema fields 249 | }), 250 | run: async ({ input, sendOutput }) => { 251 | return sendOutput({ 252 | // ...pet data 253 | }); 254 | }, 255 | }); 256 | 257 | export { handler as GET }; 258 | ``` 259 | 260 | The rest of the documentation will provide more details about how Clover works e.g. a TRPC-style inferred client, and how to serve the generated OpenAPI schema. 261 | 262 | ## Other concepts 263 | 264 | ### Runtime typesafety with Zod 265 | 266 | Just having TypeScript types (either inferred from server code or generated from an OpenAPI spec) doesn't gurarantee type-safety over the wire during runtime. What if there was a cosmic bitflip ☀️💀 when the data was enroute from the server to the client? To gurarantee type-safety, it's generally a good idea to use a schema validation library like Zod, which is what you see with all the `z.object` stuff in the code examples above. You can learn more about Zod at https://github.com/colinhacks/zod. 267 | 268 | ```ts 269 | // an example from Zod's documentation 270 | 271 | import { z } from "zod"; 272 | 273 | // the schema 274 | const User = z.object({ 275 | username: z.string(), 276 | }); 277 | 278 | // the type guarantee 279 | type User = z.infer; 280 | // { username: string } 281 | 282 | // the runtime guarantee 283 | User.parse({ username: "Ludwig" }); 284 | ``` 285 | -------------------------------------------------------------------------------- /packages/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import { Callout } from "nextra-theme-docs"; 2 | import { Tab, Tabs } from "nextra-theme-docs"; 3 | 4 | # Clover ☘️ 5 | 6 | Server routes augmented with Zod and OpenAPI 7 | 8 |
9 | 10 | 18 | 19 | > Cover from Midjourney 20 | 21 | 22 | If you find inaccuracies or anything missing, please open an issue on GitHub! 23 | 24 | 25 | ## Installation 26 | 27 | 28 | 29 | 30 | ```bash 31 | pnpm i @sarim.garden/clover 32 | ``` 33 | 34 | 35 | 36 | 37 | ```bash 38 | npm i @sarim.garden/clover 39 | ``` 40 | 41 | 42 | 43 | 44 | ```bash 45 | yarn add @sarim.garden/clover 46 | ``` 47 | 48 | 49 | 50 | 51 | ## Introduction 52 | 53 | Clover is a library that allows you to define your server routes using Zod and OpenAPI. You can use Clover for making your server routes type-safe and self-documenting, and as a lighterweight TRPC/Zodios replacement. 54 | 55 | You can use Clover with any framework/runtime that supports WinterCG style functions e.g. 56 | 57 | ```ts 58 | const handler = (request: Request) => { 59 | return new Response("Hello World!"); 60 | }; 61 | ``` 62 | 63 | ### Server 64 | 65 | Here's what a handler augmented with Clover looks like: 66 | 67 | ```ts 68 | // server.ts 69 | 70 | import { makeRequestHandler } from "@sarim.garden/clover"; 71 | import { z } from "zod"; 72 | 73 | export const { handler } = makeRequestHandler({ 74 | method: "GET", 75 | path: "/hello", 76 | description: "Returns a greeting", 77 | input: z.object({ 78 | name: z.string(), 79 | }), 80 | output: z.object({ 81 | greeting: z.string(), 82 | }), 83 | run: async ({ input, sendOutput }) => { 84 | return sendOutput({ 85 | greeting: `Hello ${input.name}!`, 86 | }); 87 | }, 88 | }); 89 | ``` 90 | 91 | You can learn more about how to add authentication, about Clover's heuristics for query parameters, path parameters, request bodies etc. by reading the [server documentation](/server). 92 | 93 | #### OpenAPI 94 | 95 | You can generate OpenAPI documentation from your server routes. 96 | 97 | ```ts 98 | // server.ts 99 | 100 | export const { handler, openAPIPathsObject } = makeRequestHandler({ 101 | // ...same as above 102 | }); 103 | ``` 104 | 105 | ```ts 106 | // openapi.ts 107 | 108 | import { OpenAPIObject, OpenAPIPathsObject } from "@sarim.garden/clover"; 109 | import { openAPIPathsObject } from "./server"; 110 | 111 | // it's ugly, I know, but this nicely combines 112 | // multiple openAPI definitions from different route handlers :) 113 | const pathsObject: OpenAPIPathsObject = [ 114 | openAPIPathsObject, 115 | // ...add others here 116 | ].reduce((acc, curr) => { 117 | Object.keys(curr).forEach((k) => { 118 | acc[k] = { 119 | ...acc[k], 120 | ...curr[k], 121 | }; 122 | }); 123 | return acc; 124 | }, {}); 125 | 126 | export const document: OpenAPIObject = { 127 | info: { 128 | title: "My API", 129 | version: "1.0.0", 130 | }, 131 | openapi: "3.0.0", 132 | paths: pathsObject, 133 | }; 134 | ``` 135 | 136 | You can now serve this document as JSON, and use it with Swagger UI or similar tools. 137 | 138 | ### Client 139 | 140 | Clover also provides an optional client fetcher. It's nothing fancy, it just wraps the Fetch API and provides type safety. 141 | 142 | ```ts 143 | // server.ts 144 | 145 | const { handler, clientConfig } = makeRequestHandler({ 146 | // ...same as above 147 | }); 148 | 149 | export { handler as GET }; 150 | export type clientTypes = typeof clientConfig; 151 | ``` 152 | 153 | ```ts 154 | // client.ts 155 | 156 | import { makeFetcher } from "@sarim.garden/clover"; 157 | import type { clientTypes } from "./server"; 158 | 159 | const fetcher = makeFetcher({ 160 | baseUrl: "https://example.com", 161 | }); 162 | 163 | const { greeting } = await fetcher({ 164 | input: { 165 | name: "Sarim", 166 | }, 167 | }); 168 | ``` 169 | 170 | You can read more about how the fetcher works in the [client documentation](/client). 171 | 172 | ## Credits 173 | 174 | ### Similar projects 175 | 176 | - TRPC: https://github.com/trpc/trpc 177 | - Zodios: https://github.com/ecyrbe/zodios 178 | - ts-rest: https://github.com/ts-rest/ts-rest 179 | - feTS: https://github.com/ardatan/fets 180 | - zaCT: https://github.com/pingdotgg/zact 181 | 182 | ### Enabling libraries 183 | 184 | - Zod: https://github.com/colinhacks/zod/ 185 | - @anatine/zod-openapi: https://github.com/anatine/zod-plugins 186 | - OpenApi3-TS: https://github.com/metadevpro/openapi3-ts 187 | -------------------------------------------------------------------------------- /packages/docs/pages/server.mdx: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | ## Example 4 | 5 | ```ts 6 | const { handler, clientConfig, openAPIPathsObject } = makeRequestHandler({ 7 | input: z.object({ 8 | name: z.string(), 9 | }), 10 | output: z.object({ 11 | greeting: z.string(), 12 | }), 13 | run: async ({ request, input, sendOutput }) => { 14 | const { name } = input; 15 | return sendOutput({ greeting: `Hello, ${name}!` }); 16 | }, 17 | path: "/api/hello", 18 | method: "GET", 19 | description: "Greets the user", 20 | authenticate: async (req) => { 21 | return true; 22 | }, 23 | }); 24 | ``` 25 | 26 | ## Props 27 | 28 | | Name | Type | Description | 29 | | :-------------- | :------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 30 | | `input` | `AnyZodObject` | A Zod schema describing the shape of your input. Clover will look for the input inside query params, path params or JSON formatted request body. | 31 | | `output` | `AnyZodObject` | A Zod schema describing the shape of your output. You can use the `sendOutput` helper inside your `run` function to ensure that you conform to the output. | 32 | | `run` | `({ request, input, sendOutput }) => Promise` | The logic you want to run. You can use the validated input, or use the raw request. You can also send the response any way you like, but the `sendOutput` helper will help you conform to the output schema. | 33 | | `path` | `string` | The relative path where your handler can be reached e.g. `/api/hello-world` | 34 | | `method` | `HTTPMethod` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. This helps Clover generate appropriate documentation, and also helps it figure out where to look for the input. For example, input will be parsed from query and path parameters for `GET` requests. | 35 | | `description?` | `string` | Useful for generating OpenAPI documentation for this route. | 36 | | `authenticate?` | `(request: Request) => Promise` | If you supply this property, it will mark the route as protected with Bearer auth in the documentation. You can verify authentication status any way you like. Return `true` if the request is authenticated. | 37 | 38 | ## Return values 39 | 40 | | Name | Type | Description | 41 | | :------------------- | :------------------------------- | :-------------------------------------------------------------------------------------------------------- | 42 | | `handler` | `(request: Request) => Response` | An augmented server route handler. | 43 | | `clientConfig` | `IClientConfig` | A dummy variable used to extract types and pass them to the client. You can read more in the Client docs. | 44 | | `openAPIPathsObject` | `oas31.PathsObject` | A generated OpenAPI schema for this route. You can read more about how to use this in the OpenAPI docs. | 45 | 46 | ## OpenAPI 47 | 48 | Clover uses `openapi3-ts` and `@anatine/zod-openapi` to generate OpenAPI schemas for your routes. Each route returns an `oas31.PathsObject`. You can stitch together all the schemas into a combined document like below: 49 | 50 | ```ts 51 | // openapi.ts 52 | 53 | import { OpenAPIObject, OpenAPIPathsObject } from "@sarim.garden/clover"; 54 | import { openAPIPathsObject as someRouteOpenAPISchema } from "./some/route"; 55 | import { openAPIPathsObject as anotherRouteOpenAPISchema } from "./another/route"; 56 | 57 | const pathsObject: OpenAPIPathsObject = [ 58 | someRouteOpenAPISchema, 59 | anotherRouteOpenAPISchema, 60 | ].reduce((acc, curr) => { 61 | Object.keys(curr).forEach((k) => { 62 | acc[k] = { 63 | ...acc[k], 64 | ...curr[k], 65 | }; 66 | }); 67 | return acc; 68 | }, {}); 69 | 70 | export const document: OpenAPIObject = { 71 | info: { 72 | title: "My API", 73 | version: "1.0.0", 74 | }, 75 | openapi: "3.0.0", 76 | paths: pathsObject, 77 | }; 78 | ``` 79 | 80 | ## Usage with frameworks 81 | 82 | ### Next.js 83 | 84 | Clover works with standard Web Request and Response APIs, which are only available in the new `app` directory in Next.js 13.4. 85 | 86 | ```ts 87 | // app/hello/route.ts 88 | 89 | const { handler } = makeRequestHandler({ 90 | method: "GET", 91 | // ... 92 | }); 93 | 94 | export { handler as GET }; 95 | ``` 96 | 97 | #### Swagger UI 98 | 99 | Setting up Swagger would vary from framework to framework, but here is an illustrative example for Next.js: 100 | 101 | ```ts 102 | // app/openapi.json/route.ts 103 | 104 | import { document } from "../../openapi"; 105 | import { NextResponse } from "next/server"; 106 | 107 | export const GET = () => { 108 | return NextResponse.json(document); 109 | }; 110 | ``` 111 | 112 | ```ts 113 | // app/swagger/page.tsx 114 | 115 | "use client"; 116 | 117 | import "swagger-ui-react/swagger-ui.css"; 118 | import SwaggerUI from "swagger-ui-react"; 119 | import { useEffect, useState } from "react"; 120 | 121 | const SwaggerPage = () => { 122 | const [mounted, setMounted] = useState(false); 123 | 124 | useEffect(() => { 125 | setMounted(true); 126 | }, []); 127 | 128 | if (!mounted) { 129 | return null; 130 | } 131 | 132 | return ; 133 | }; 134 | 135 | export default SwaggerPage; 136 | ``` 137 | 138 | ## Input parsing 139 | 140 | There are three supported input types: query parameters, path parameters and JSON request bodies. Depending on the HTTP method used, Clover will parse the input from the appropriate source. 141 | 142 | | Method | Input source | 143 | | :------- | :----------- | 144 | | `GET` | Path + query | 145 | | `DELETE` | Path + query | 146 | | `POST` | Path + body | 147 | | `PUT` | Path + body | 148 | | `PATCH` | Path + body | 149 | 150 | ### Path parameters 151 | 152 | Clover uses [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) to parse path parameters e.g. if you have an input schema `z.object({ id: z.string() })` and path `/api/users/:id`, then Clover will parse the request URL to find the ID and use it to populate the input inside the `run()` function. 153 | -------------------------------------------------------------------------------- /packages/docs/public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/packages/docs/public/cover.png -------------------------------------------------------------------------------- /packages/docs/public/eli5/1_openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/packages/docs/public/eli5/1_openapi.png -------------------------------------------------------------------------------- /packages/docs/public/eli5/2_more_arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/packages/docs/public/eli5/2_more_arrows.png -------------------------------------------------------------------------------- /packages/docs/public/eli5/3_full_stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarimabbas/clover/78df5b146a9e3f08b180125b8cd68a089e4d86f2/packages/docs/public/eli5/3_full_stack.png -------------------------------------------------------------------------------- /packages/docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import { DocsThemeConfig } from "nextra-theme-docs"; 2 | 3 | const config: DocsThemeConfig = { 4 | logo: Clover ☘️, 5 | useNextSeoProps() { 6 | return { 7 | titleTemplate: "%s – Clover", 8 | }; 9 | }, 10 | head: ( 11 | <> 12 | 13 | 14 | 18 | 22 | 23 | ), 24 | footer: { 25 | text:

MIT {new Date().getFullYear()} © Clover.

, 26 | }, 27 | project: { 28 | link: "https://github.com/sarimabbas/clover", 29 | }, 30 | docsRepositoryBase: 31 | "https://github.com/sarimabbas/clover/tree/main/packages/docs", 32 | }; 33 | 34 | export default config; 35 | -------------------------------------------------------------------------------- /packages/docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "turbo", "prettier"], 3 | rules: { 4 | "@next/next/no-html-link-for-pages": "off", 5 | }, 6 | parserOptions: { 7 | babelOptions: { 8 | presets: [require.resolve("next/babel")], 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "main": "index.js", 4 | "version": "1.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "eslint": "latest", 8 | "eslint-config-next": "latest", 9 | "eslint-config-prettier": "latest", 10 | "eslint-config-turbo": "latest", 11 | "eslint-plugin-react": "latest", 12 | "next": "13.3.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "pipeline": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"] 8 | }, 9 | "lint": {}, 10 | "dev": { 11 | "cache": false, 12 | "persistent": true 13 | } 14 | } 15 | } 16 | --------------------------------------------------------------------------------