diff --git a/README.md b/README.md index 750a079..4dd12e5 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ Set of tools to create typesafe APIs using AdonisJS. The monorepo includes the f The main goal of this project is to provide some utilities to have better typesafety when creating APIs with AdonisJS. Goals on the long term are : -- **Done (Experimental)** : Provide an RPC-like client that is fully e2e typesafe ( like tRPC, Elysia Eden, Hono etc. ) -- **Done (Experimental)** : Provide a [Ziggy](https://github.com/tighten/ziggy)-like helper to generate and use routes in the frontend. +- **Done** : Provide an RPC-like client that is fully e2e typesafe ( like tRPC, Elysia Eden, Hono etc. ) +- **Done** : Provide a [Ziggy](https://github.com/tighten/ziggy)-like helper to generate and use routes in the frontend. - **Done (Experimental)** : Having an automatic OpenAPI generation + Swagger/Scalar UI viewer based on Tuyau codegen. - **In Progress** : Provide some Inertia helpers to have better typesafety when using Inertia in your AdonisJS project. Things like typesafe `` and `useForm`. - **Not started** : Provide a specific Controller class that will allow to have better typesafety when creating your endpoints. +- **Not started**: Having a Tanstack-Query integration for the client package. Like [tRPC](https://trpc.io/docs/client/react) or [ts-rest](https://ts-rest.com/docs/vue-query) does. ## Installation @@ -455,201 +456,3 @@ export default defineConfig({ Only one of `only` or `except` can be used at the same time. Both accept either an array of strings, an array of regular expressions or a function that will receive the route name and must return a boolean. `definitions` will filter the generated types in the `ApiDefinition` interface. `routes` will the filter the route names that are generated in the `routes` object. - -## Inertia package - -Tuyau also provides a set of helpers for Inertia projects. The package is called `@tuyau/inertia`. First, make sure to have generated the API definition in your AdonisJS project using `@tuyau/core` and also have configured the client in your frontend project using `@tuyau/client`. - -Then, you can install the package in your frontend project : - -```bash -pnpm add @tuyau/inertia -``` -### React usage - -To use the Inertia helpers in your React x Inertia project, you must wrap your app with the `TuyauProvider` component : - -```tsx -// inertia/app/app.tsx -import { TuyauProvider } from '@tuyau/inertia/react' -import { tuyau } from './tuyau' - -createInertiaApp({ - // ... - - setup({ el, App, props }) { - hydrateRoot( - el, - <> - - - - - ) - }, -}) -``` - -As you can see, you must pass an instance of the Tuyau client to the `TuyauProvider` component. Also, if you are using SSR, make sure to also wrap your app with the `TuyauProvider` component in your `inertia/app/ssr.tsx` file. - -#### Link - -The `Link` component is a wrapper around the Inertia `Link` component with some additional typesafety. Tuyau `Link` component will accept the same props as the Inertia `Link` component except for the `href` and `method` props. They are replaced by the `route` and `params` props. - -```tsx -import { Link } from '@tuyau/inertia/react' - -Go to post -``` - -### Vue usage - -To use the Inertia helpers in your Vue x Inertia project, you must install the Tuyau plugin : - -```ts -// inertia/app/app.ts - -import { TuyauPlugin } from '@tuyau/inertia/vue' -import { tuyau } from './tuyau' - -createInertiaApp({ - // ... - - setup({ el, App, props, plugin }) { - createSSRApp({ render: () => h(App, props) }) - .use(plugin) - .use(TuyauPlugin, { client: tuyau }) - .mount(el) - }, -}) -``` - -As you can see, you must pass an instance of the Tuyau client to the `TuyauPlugin` plugin. Also, if you are using SSR, make sure to also install the `TuyauPlugin` plugin in your `inertia/app/ssr.ts` file. - -#### Link - -The `Link` component is a wrapper around the Inertia `Link` component with some additional typesafety. Tuyau `Link` component will accept the same props as the Inertia `Link` component except for the `href` and `method` props. They are replaced by the `route` and `params` props. - -```vue - - - -``` - -## OpenAPI package - -> [!WARNING] -> OpenAPI package is still experimental and may not work as expected. Please feel free to open an issue if you encounter any problem. - -The `@tuyau/openapi` package allows you to generate an OpenAPI specification for your API from the Tuyau codegen. The specification is generated from the types of routes and validation schemas, using the Typescript compiler and `ts-morph`. Therefore, it has some limitations compared to manually written specifications (whether via decorators, JS doc, YAML files, or Zod schemas describing the inputs/outputs of the routes). - -During development, the specification will be generated on the fly with each request, which can take a bit of time for large APIs with many routes. In production, the specification will be generated at build time into a `.yml` file and the same specification will be served for each request. - -### Installation - -To install the package, you will obviously need to have the `@tuyau/core` package already installed in your AdonisJS project. Then, you can install the `@tuyau/openapi` package: - -```bash -node ace add @tuyau/openapi -``` - -### Usage - -Once the package is configured, you can directly access your API's OpenAPI specification at the `/docs` address. The `/openapi` route is also available to directly access the OpenAPI specification. - -### Customizing the Specification - -To customize the specification, the package exposes `openapi` macros on the routes. These macros allow you to add additional information to the OpenAPI specification. For example, you can add tags, descriptions, responses, parameters, etc. - -```ts -router.group(() => { - router - .get("/random", [MiscController, "index"]) - .openapi({ summary: "Get a random thing" }); - - router - .get("/random/:id", [MiscController, "show"]) - .openapi({ summary: "Get a random thing by id" }); -}) - .prefix("/misc") - .openapi({ tags: ["misc"] }); -``` - -Feel free to use your editor's autocomplete to see all available options. - -Also, from the `config/tuyau.ts` file, you have the ability to customize the OpenAPI specification with the `openapi.documentation` property: - -```ts -const tuyauConfig = defineConfig({ - openapi: { - documentation: { - info: { title: 'My API!', version: '1.0.0', description: 'My super API' }, - tags: [ - { name: 'subscriptions', description: 'Operations about subscriptions' }, - ], - }, - }, -}); -``` - -### Configuration - -The package configuration is done via the `config/tuyau.ts` file, under the `openapi` key. The available options are as follows: - -#### `provider` - -The OpenAPI viewer to use. Two providers are available: `swagger-ui` and `scalar`. By default, the `scalar` provider is used. - -#### `exclude` - -Paths to exclude from the OpenAPI specification. By default, no paths are excluded. You can pass an array of strings or regex. - -Example: - -```ts -const tuyauConfig = defineConfig({ - openapi: { - exclude: ['/health', /admin/] - } -}); -``` - -#### `endpoints` - -The endpoints where the OpenAPI specification and documentation will be available. By default, the OpenAPI specification will be available at `/openapi` and the documentation at `/docs`. - -Example: - -```ts -const tuyauConfig = defineConfig({ - openapi: { - endpoints: { - spec: '/my-super-spec', - ui: '/my-super-doc' - } - } -}); -``` - -#### `scalar` - -The options to pass to the `scalar` provider when used. More details [here](https://github.com/scalar/scalar?tab=readme-ov-file#configuration). - -#### `swagger-ui` - -The options to pass to the `swagger-ui` provider when used. More details [here](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/). - -## Sponsors - -![](https://github.com/julien-r44/static/blob/main/sponsorkit/sponsors.png?raw=true) - -## Credits - -- Tuyau was inspired a lot by [Elysia](https://elysiajs.com/eden/treaty/overview), [tRPC](https://trpc.io/) and [Hono](https://hono.dev/) for the RPC client. -- Route helpers were inspired by [Ziggy](https://github.com/tighten/ziggy) - -Thanks to the authors of these projects for the inspiration ! diff --git a/docs/assets/images/banner.png b/docs/assets/images/banner.png new file mode 100644 index 0000000..3e7af42 Binary files /dev/null and b/docs/assets/images/banner.png differ diff --git a/docs/content/config.json b/docs/content/config.json index 55a2b5f..de5748d 100644 --- a/docs/content/config.json +++ b/docs/content/config.json @@ -1,26 +1,17 @@ { "links": { "home": { - "title": "Your project name", + "title": "Tuyau", "href": "/" }, "github": { - "title": "Your project on Github", - "href": "https://github.com/dimerapp" + "title": "Tuyau on GitHub", + "href": "https://github.com/Julien-R44/tuyau" } }, - "sponsors_sources": [ - ], + "sponsors_sources": ["julien-r44"], "search": { }, - "advertising_sponsors": [ - { - "link": "", - "logo": "", - "logo_dark": "", - "logo_styles": "" - } - ], - "fileEditBaseUrl": "https://github.com/dimerapp/docs-boilerplate/blob/develop", - "copyright": "Your project legal name" + "fileEditBaseUrl": "https://github.com/tuyau/tree/main/docs", + "copyright": "Tuyau" } diff --git a/docs/content/docs/client.md b/docs/content/docs/client.md new file mode 100644 index 0000000..2e184f4 --- /dev/null +++ b/docs/content/docs/client.md @@ -0,0 +1,366 @@ +--- +summary: How to use Tuyau RPC / E2E Client in your AdonisJS project +--- + +# Client + +As mentioned in the [installation](./installation.md), we used `createTuyau` to create our client instance. This instance will be used to make requests to our API. The following options are available: + +## Options + +### `api` + +The `api` object is the generated API definition from your AdonisJS project. This object contains everything needed for Tuyau to work. It contains the routes, the types, the definitions, etc. + +```ts +import { api } from '@your-monorepo/server/.adonisjs/api' + +export const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333', +}) +``` + +As you can see, the `api` is not a type, but a real runtime object. You might ask, why? The `api` is an object that contains two things: + - The definition of your API. This is just a type with no runtime code. + - The routes of your API. This is a "real" object that contains all the routes with their names and paths. Since we need to map the route names to paths, we need some runtime code for that. + +If you're not interested in using the route names in your frontend project, you can simply import the `ApiDefinition` type from the `@tuyau/client` package and ignore the `api` object: + +```ts +/// + +import { createTuyau } from '@tuyau/client' +import type { ApiDefinition } from '@your-monorepo/server/.adonisjs/api' + +export const tuyau = createTuyau<{ definition: ApiDefinition }>({ + baseUrl: 'http://localhost:3333', +}) +``` + +To clarify, if you don't need to use methods like `tuyau.$url('users.posts.show', { id: 1, postId: 2 })`, `$tuyau.route`, or the `Link` component from `@tuyau/inertia`, you can ignore the `api` object and only pass the `ApiDefinition` type to `createTuyau`. + +### `baseUrl` + +The `baseUrl` option is the base URL of your API. + +```ts +export const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333', +}) +``` + +### Other options + +The Tuyau client is built on top of [Ky](https://github.com/sindresorhus/ky). So you can pass any options supported by Ky. Here's an example with some options: + +```ts +const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333', + timeout: 10_000, + headers: { 'X-Custom-Header': 'foobar' }, + hooks: { + beforeRequest: [ + (request) => { + const token = getToken() + if (token) { + request.headers.set('Authorization', `Bearer ${token}`) + } + } + ] + } +}) +``` + +## Making requests + +Making requests with Tuyau is pretty straightforward. Essentially, you need to chain the different parts of the route you want to call using `.` instead of `/` and then call the `$method` you want to use. Let's look at some examples: + +```ts +import { tuyau } from './tuyau' + +// GET /users +await tuyau.users.$get() + +// POST /users { name: 'John Doe' } +await tuyau.users.$post({ name: 'John Doe' }) + +// PUT /users/1 { name: 'John Doe' } +await tuyau.users({ id: 1 }).$put({ name: 'John Doe' }) + +// GET /users/1/posts?limit=10&page=1 +await tuyau.users.$get({ query: { page: 1, limit: 10 } }) +``` + +## Making Requests using the route name + +If you prefer to use route names instead of paths, you can use the `$route` method: + +```ts +// Backend +router.get('/posts/:id/generate-invitation', '...') + .as('posts.generateInvitation') + +// Client +await tuyau + .$route('posts.generateInvitation', { id: 1 }) + .$get({ query: { limit: 10, page: 1 } }) +``` + +## Path parameters + +When calling a route with path parameters, pass an object to the related function. For example: + +```ts +// Backend +router.get('/users/:id/posts/:postId/comments/:commentId', '...') + +// Frontend +const result = await tuyau.users({ id: 1 }) + .posts({ postId: 2 }) + .comments({ commentId: 3 }) + .$get() +``` + +## Request Parameters + +You can pass specific `Ky` options to the request by providing them as a second argument to the request method: + +```ts +await tuyau.users.$post({ name: 'John Doe' }, { + headers: { + 'X-Custom-Header': 'foobar' + } +}) +``` + +When using the `$get` method, you can pass a `query` object to the request: + +```ts +await tuyau.users.$get({ + headers: { 'X-Custom-Header': 'foobar' }, + query: { page: 1, limit: 10 } +}) +``` + +Note that the `query` object will automatically be serialized into a query string with the following rules: + +- If the value is an array, it will be serialized using the `brackets` format. For example, `{ ids: [1, 2, 3] }` will be serialized as `ids[]=1&ids[]=2&ids[]=3`. +- If the value is null or undefined, it will be ignored and not added to the query string. + +## File uploads + +You can pass `File` instances to the request to upload files. Here's an example: + +```html + +``` + +```ts +const fileInput = document.getElementById('file') as HTMLInputElement +const file = fileInput.files[0] + +await tuyau.users.$post({ avatar: file }) +``` + +When a `File` instance is passed, Tuyau will automatically convert it to a `FormData` instance and set the appropriate headers. The payload will be serialized using the [`object-to-formdata`](https://www.npmjs.com/package/object-to-formdata) package. + +If you're using React Native, pass your file as follows: + +```ts +await tuyau.users.$post({ + avatar: { + uri: 'file://path/to/file', + type: 'image/jpeg', + name: 'avatar.jpg' + } +}) +``` + +## Responses + +For every request, Tuyau returns a promise with the following types: + +- `data`: The response data if the status is 2xx +- `error`: The error data if the status is 3xx +- `status`: The response's status code +- `response`: The full response object + +You must narrow the type of the response. That means you should check if the status is 2xx or 3xx and use the `data` or `error` property accordingly. + +Here's a simple example. A route returns a 401 if the password is incorrect; otherwise, it returns a secret token: + +```ts +// Backend +class MyController { + public async login({ request, response }) { + const { email, password } = request.validateUsing(schema) + if (password !== 'password') { + return response.unauthorized({ message: 'Invalid credentials' }) + } + + return { token: 'secret-token' } + } +} + +router.post('/login', [MyController, 'login']) + +// Frontend +const { data, error } = await tuyau.login.$post({ email: 'foo@ok.com', password: 'password' }) + +data +// ^? { token: string } | null + +if (error?.status === 401) { + console.error('Wrong password !!') + return +} + +console.log(data.token) +// ^? { token: string } +// data.token will be available and unwrapped here +``` + +Without narrowing the response type, `data` and `error` could be `undefined`, so you must check before using them. + +### Unwrapping the response + +If you prefer not to handle errors in your code, you can use the `unwrap` method to unwrap the response and throw an error if the status is not 2xx. + +```ts +const result = await tuyau.login.$post({ email: 'foo@ok.com' }).unwrap() +console.log(result.token) +``` + +## Inferring request and response types + +The client package provides helpers to infer the request and response types of a route. For example: + +```ts +import type { InferResponseType, InferErrorType, InferRequestType } from '@tuyau/client'; + +// InferRequestType +type LoginRequest = InferRequestType; + +// InferResponseType +type LoginResponse = InferResponseType; + +// InferErrorType +type LoginError = InferErrorType; +``` + +## Generating URL + +If you need to generate the URL of a route without making the request, you can use the `$url` method: + +```ts +const url = tuyau.users.$url() +console.log(url) // http://localhost:3333/users + +const url = tuyau.users({ id: 1 }).posts({ postId: 2 }).$url() +console.log(url) // http://localhost:3333/users/1/posts/2 +``` + +### Generating URL from route name + +To generate a + + URL using the route name, you can use the `$url` method. This is similar to how [Ziggy](https://github.com/tighten/ziggy) works: + +```ts +// http://localhost:3333/users/1/posts/2 +tuyau.$url('users.posts', { id: 1, postId: 2 }) + +// http://localhost:3333/venues/1/events/2 +tuyau.$url('venues.events.show', [1, 2]) + +// http://localhost:3333/users?page=1&limit=10 +tuyau.$url('users', { query: { page: 1, limit: 10 } }) +``` + +If you're familiar with Ziggy and prefer a `route` method instead of `$url`, you can easily define a custom method in your client file: + +```ts +export const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333' +}) + +window.route = tuyau.$url.bind(tuyau) +``` + +You can then use the `route` method in your frontend code: + +```tsx +export function MyComponent() { + return ( +
+ Go to post +
+ ) +} +``` + +## Checking the current route + +Tuyau has helpers to check the current route. You can use the `$current` method to get or verify the current route: + +```ts +// Current window location is http://localhost:3000/users/1/posts/2, route name is users.posts.show +tuyau.$current() // users.posts +tuyau.$current('users.posts.show') // true +tuyau.$current('users.*') // true +tuyau.$current('users.edit') // false +``` + +You can also specify route parameters or query parameters to check: + +```ts +tuyau.$current('users.posts.show', { params: { id: 1, postId: 2 } }) // true +tuyau.$current('users.posts.show', { params: { id: 12 } }) // false +tuyau.$current('users.posts.show', { query: { page: 1 } }) // false +``` + +## Checking if a route exists + +To check if a route name exists, you can use the `$has` method. You can also use wildcards in the route name: + +```ts +tuyau.$has('users') // true +tuyau.$has('users.posts') // true +tuyau.$has('users.*.comments') // true +tuyau.$has('users.*') // true +tuyau.$has('non-existent') // false +``` + +## Filtering generated routes + +If you need to filter the routes generated by Tuyau, you can use the `only` and `except` options in `config/tuyau.ts`: + +```ts +export default defineConfig({ + codegen: { + definitions: { + only: [/users/], + // OR + except: [/users/] + }, + + routes: { + only: [/users/], + // OR + except: [/users/] + } + } +}) +``` + +You can use only one of `only` or `except` at the same time. Both options accept an array of strings, an array of regular expressions, or a function that receives the route name and returns a boolean. + +`definitions` will filter the generated types in the `ApiDefinition` interface. `routes` will filter the route names generated in the `routes` object. + +--- + +Let me know if you need any adjustments! diff --git a/docs/content/docs/db.json b/docs/content/docs/db.json index 3771db2..b9b32ac 100644 --- a/docs/content/docs/db.json +++ b/docs/content/docs/db.json @@ -4,5 +4,29 @@ "title": "Introduction", "contentPath": "./introduction.md", "category": "Guides" + }, + { + "permalink": "installation", + "title": "Installation", + "contentPath": "./installation.md", + "category": "Guides" + }, + { + "permalink": "Client", + "title": "Client", + "contentPath": "./client.md", + "category": "Guides" + }, + { + "permalink": "Inertia", + "title": "Inertia", + "contentPath": "./inertia.md", + "category": "Guides" + }, + { + "permalink": "OpenAPI", + "title": "OpenAPI", + "contentPath": "./openapi.md", + "category": "Guides" } -] \ No newline at end of file +] diff --git a/docs/content/docs/inertia.md b/docs/content/docs/inertia.md new file mode 100644 index 0000000..dda5d9a --- /dev/null +++ b/docs/content/docs/inertia.md @@ -0,0 +1,83 @@ +# Inertia package + +Tuyau also provides a set of helpers for Inertia projects. The package is called `@tuyau/inertia`. First, make sure to have generated the API definition in your AdonisJS project using `@tuyau/core` and also have configured the client in your frontend project using `@tuyau/client`. + +Then, you can install the package in your frontend project : + +```bash +pnpm add @tuyau/inertia +``` +## React usage + +To use the Inertia helpers in your React x Inertia project, you must wrap your app with the `TuyauProvider` component : + +```tsx +// inertia/app/app.tsx +import { TuyauProvider } from '@tuyau/inertia/react' +import { tuyau } from './tuyau' + +createInertiaApp({ + // ... + + setup({ el, App, props }) { + hydrateRoot( + el, + <> + + + + + ) + }, +}) +``` + +As you can see, you must pass an instance of the Tuyau client to the `TuyauProvider` component. Also, if you are using SSR, make sure to also wrap your app with the `TuyauProvider` component in your `inertia/app/ssr.tsx` file. + +### Link + +The `Link` component is a wrapper around the Inertia `Link` component with some additional typesafety. Tuyau `Link` component will accept the same props as the Inertia `Link` component except for the `href` and `method` props. They are replaced by the `route` and `params` props. + +```tsx +import { Link } from '@tuyau/inertia/react' + +Go to post +``` + +## Vue usage + +To use the Inertia helpers in your Vue x Inertia project, you must install the Tuyau plugin : + +```ts +// inertia/app/app.ts + +import { TuyauPlugin } from '@tuyau/inertia/vue' +import { tuyau } from './tuyau' + +createInertiaApp({ + // ... + + setup({ el, App, props, plugin }) { + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(TuyauPlugin, { client: tuyau }) + .mount(el) + }, +}) +``` + +As you can see, you must pass an instance of the Tuyau client to the `TuyauPlugin` plugin. Also, if you are using SSR, make sure to also install the `TuyauPlugin` plugin in your `inertia/app/ssr.ts` file. + +### Link + +The `Link` component is a wrapper around the Inertia `Link` component with some additional typesafety. Tuyau `Link` component will accept the same props as the Inertia `Link` component except for the `href` and `method` props. They are replaced by the `route` and `params` props. + +```vue + + + +``` diff --git a/docs/content/docs/installation.md b/docs/content/docs/installation.md new file mode 100644 index 0000000..da44b52 --- /dev/null +++ b/docs/content/docs/installation.md @@ -0,0 +1,136 @@ +--- +summary: Setup Tuyau in your application +--- + +# Installation + +:::warning +- Tuyau is an ESM-only package. You will also need Node.js 18 or higher. +- This guide will assume you have a monorepo with your AdonisJS project, and your frontend project. As with most E2E client tools, it is highly recommended to have a monorepo to facilitate the generation and use of the client. +::: + +First make sure to install the code package in your AdonisJS project : + +```bash +node ace add @tuyau/core +``` + +Then, you can install the client package in your frontend project: + +```bash +pnpm add @tuyau/client +``` + +## Usage + +### Core package + +The core package expose a single command called `node ace tuyau:generate`. This command will generate the typescript types needed for the client package to work. + +**This command will NOT run automatically for now. You will need to run it manually after some specific changes in your AdonisJS project :** + +- After adding a new route/controller in your project +- After adding a `request.validateUsing` call in your controller method. + +Other than that, you will not need to run this command. Let's say you update the return type of a controller method, or you update the Vine schema : **you DON'T need to run the command.** + +Later on, we will use the [`onSourceFileChanged` hook](https://docs.adonisjs.com/guides/experimental-assembler-hooks#onsourcefilechanged) to run the command automatically when needed. + +To run the command manually, you must run : + +```bash +node ace tuyau:generate +``` + +And an appropriate `.adonisjs/api.ts` file will be generated in your project. + +### Sharing the API definition + +The command will generate a file called `.adonisjs/api.ts` in your project. This file will contain the definition of your API. You must export this file in your project to use the client package. + +So, let's say your monorepo structure is like this : + +``` +apps + frontend + server +``` + +You must export the `.adonisjs/api.ts` file from your server workspace using [subpath exports](https://nodejs.org/api/packages.html#subpath-exports) : + +```jsonc +// package.json +{ + "name": "@acme/server", + "type": "module", + "version": "0.0.0", + "private": true, + "exports": { + "./api": "./.adonisjs/api.ts" + }, +} +``` + +Once done, make sure to include `@acme/server` as a dependency in your frontend workspace : + +```jsonc +// package.json +{ + "name": "@acme/frontend", + "type": "module", + "version": "0.0.0", + "private": true, + "dependencies": { + "@acme/server": "workspace:*" + } +} +``` + +:::warning +Make sure you package manager or monorepo tool is able to resolve the `workspace:*` syntax. If not, you will need to use whatever syntax your tool is using. +::: + +Then you should be able to import the API definition in your frontend project : + +```ts +import { api } from '@acme/server/api' +``` + +#### Initializing the client + +Once installed, you must create the tuyau client in your frontend project : + +```ts +/// + +import { createTuyau } from '@tuyau/client' +import { api } from '@your-monorepo/server/.adonisjs/api' + +export const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333', +}) +``` + +Multiple things to note here : + +- We must reference the `adonisrc.ts` file at the top of the file. By doing that, the frontend project will be aware of some types defined in the AdonisJS project. +- We must import `api` from the `.adonisjs/api` file in your AdonisJS project. You should change the path to match your project structure. +- As you can see, the `api` is not a type, but a real object. You may ask why ? `api` is an object that contains two things : + - The definition of you API. This is just a type. No runtime code for that. + - The routes of your API. This is a "real" object that contains all the routes with their names and paths. Since we need to map the route names to the paths, we need to have some runtime code for that. + +If you are not interested in using the route names in your frontend project, you can just import the `ApiDefinition` type from the `@tuyau/client` package and ignore the `api` object : + +```ts +/// + +import { createTuyau } from '@tuyau/client' +import type { ApiDefinition } from '@your-monorepo/server/.adonisjs/api' + +export const tuyau = createTuyau<{ definition: ApiDefinition }>({ + baseUrl: 'http://localhost:3333', +}) +``` + +By doing that, you will not have additional runtime code in your frontend project but you will lose the ability to use the route names in your frontend project ( `$has`, `$current`, `$route` and other route helpers ). However, you will still benefit from the typesafety of the API definition when calling your routes by their path ( e.g. `tuyau.users.$get()` ). diff --git a/docs/content/docs/introduction.md b/docs/content/docs/introduction.md index 723e4f1..349e219 100644 --- a/docs/content/docs/introduction.md +++ b/docs/content/docs/introduction.md @@ -1,277 +1,156 @@ -# Docs boilerplate +# Introduction -The boilerplate repo we use across AdonisJS projects to create a documentation website. The boilerplate allows for maximum customization without getting verbose. +![](assets/images/banner.png) -## Why not use something like VitePress? -I have never been a big fan of a frontend first tooling when rendering markdown files to static HTML. I still remember the Gridsome and Gatsby days, when it was considered normal to use GraphQL to build a static website πŸ˜‡. +A set of tools to create typesafe APIs with AdonisJS, including an E2E client like tRPC, Inertia helpers, Ziggy-like and more. -With that said, the [feature set around rendering markdown](https://vitepress.dev/guide/markdown) feels modern and refreshing with frontend tooling. But, the underlying libraries are not limited to the frontend ecosystem, and you can use them within any JavaScript project. +- **E2E typesafe client**: Generate a client to consume your AdonisJS API with 100% typesafety. +- **Ziggy-like helper**: Generate and use routes in the frontend with typesafety. +- **OpenAPI generation**: Generate an OpenAPI definition from your AdonisJS project based on Tuyau codegen. +- **Inertia helpers**: A set of components and helpers for AdonisJS + Inertia projects. -So, if I have all the tools at my disposal, why not build and use something simple that does not change with the new wave of innovation in the frontend ecosystem? +## Project Goals -## Workflow -The docs boilerplate is built around the following workflow requirements. +The main goal of this project is to provide utilities for better typesafety when creating APIs with AdonisJS. Long-term goals include: -- Create a highly customizable markdown rendering pipeline. I need control over rendering every markdown element and tweaking its HTML output per my requirements. This is powered by [@dimerapp/markdown](https://github.com/dimerapp/markdown) and [@dimerapp/edge](https://github.com/dimerapp/edge) packages. +- **Done** : Provide an RPC-like client that is fully e2e typesafe ( like tRPC, Elysia Eden, Hono etc. ) +- **Done** : Provide a [Ziggy](https://github.com/tighten/ziggy)-like helper to generate and use routes in the frontend. +- **Done (Experimental)** : Having an automatic OpenAPI generation + Swagger/Scalar UI viewer based on Tuyau codegen. +- **In Progress** : Provide some Inertia helpers to have better typesafety when using Inertia in your AdonisJS project. Things like typesafe `` and `useForm`. +- **Not started** : Provide a specific Controller class that will allow to have better typesafety when creating your endpoints. +- **Not started**: Having a Tanstack-Query integration for the client package. Like [tRPC](https://trpc.io/docs/client/react) or [ts-rest](https://ts-rest.com/docs/vue-query) does. -- Use [Shiki](https://github.com/shikijs/shiki) for styling codeblocks. Shiki uses VSCode themes and grammar for syntax highlighting and requires zero frontend code. +## E2E/RPC-like Client? What does that mean? -- Use a [base HTML and CSS theme](https://github.com/dimerapp/docs-theme) to avoid re-building documentation websites from scratch every time. But still allow customizations to add personality to each website. +Imagine you have an AdonisJS API ready to use, and you want to consume it in your frontend React/Vue or another framework. Typically, you'd create a class or file that contains methods to call the various routes of your API: -- Use a dumb JSON file to render the docs sidebar (JSON database file). Scanning files & folders and sorting them by some convention makes refactoring a lot harder. - -- Allow linking to markdown files and auto-resolve their URLs when rendering to HTML. - -- Allow keeping images and videos next to markdown content and auto-resolve their URLs when rendering to HTML. - -## Folder structure +```typescript +export class MyAPI { + async getPosts(options) { + return fetch( + `/posts?page=${options.page}&limit=${options.limit}` + ) + } + async getPost(id: number) { + return fetch(`/posts/${id}`) + } +} ``` -. -β”œβ”€β”€ assets -β”‚ β”œβ”€β”€ app.css -β”‚ └── app.js -β”œβ”€β”€ bin -β”‚ β”œβ”€β”€ build.ts -β”‚ └── serve.ts -β”œβ”€β”€ content -β”‚ β”œβ”€β”€ docs -β”‚ └── config.json -β”œβ”€β”€ src -β”‚ β”œβ”€β”€ bootstrap.ts -β”‚ └── collections.ts -β”œβ”€β”€ templates -β”‚ β”œβ”€β”€ elements -β”‚ β”œβ”€β”€ layouts -β”‚ β”œβ”€β”€ partials -β”‚ └── docs.edge -β”œβ”€β”€ vscode_grammars -β”‚ β”œβ”€β”€ dotenv.tmLanguage.json -β”‚ └── main.ts -β”œβ”€β”€ package-lock.json -β”œβ”€β”€ package.json -β”œβ”€β”€ README.md -β”œβ”€β”€ tsconfig.json -└── vite.config.js -``` - -### The assets directory - -The `assets` directory has the CSS and frontend JavaScript entry point files. Mainly, we import additional packages and the [base theme](https://github.com/dimerapp/docs-theme) inside these files. However, feel free to tweak these files to create a more personalized website. - -### The bin directory - -The `bin` directory has two script files to start the development server and export the docs to static HTML files. These scripts boot the AdonisJS framework under the hood. - -### The content directory -The `content` directory contains the markdown and database JSON files. We organize markdown files into collections, each with its database file. - -You can think of collections as different documentation areas on the website. For example: You can create a **collection for docs**, a **collection for API** reference, and a **collection for config reference**. - -See also: [Creating new collections](#creating-new-collections) -### The src directory -The `src` directory has a `bootstrap` file to wire everything together. We do not hide the bootstrap process inside some packages. This is because we want the final projects to have complete control over configuring, pulling in extra packages, or removing unused features. +This works, but there’s no type safety. There’s no information about the data being sent or received. Also boring code to write. The next step is usually to create types for the data: -The `collections.ts` file is used to define one or more collections. +```typescript +interface Post { + id: number + title: string +} -### The templates directory -The `templates` directory contains the Edge templates used for rendering HTML. +interface GetPostsOptions { + page?: number + limit?: number +} -- The `docs` template renders a conventional documentation layout with the header, sidebar, content, and table of contents. You may use the same template across multiple collections. -- The logos are kept as SVG inside the `partials/logo.edge` and `partials/logo_mobile.edge` files. -- The base HTML fragment is part of the `layouts/main.edge` file. Feel free to add custom meta tags or scripts/fonts inside this file. +export class MyAPI { + async getPosts(options: GetPostsOptions): Promise { + return fetch( + `/posts?page=${options.page}&limit=${options.limit}` + ) + } -### The vscode_grammars directory -The `vscode_grammars` directory contains a collection of custom VSCode languages you want to use inside your project. + async getPost(id: number): Promise { + return fetch(`/posts/${id}`) + } +} +``` -See also: [Using custom VSCode grammars](#using-custom-vscode-grammars) +While this is better, we still have the problem of keeping types in sync. If a field name changes in your API, you’ll need to update it in the client too, leading to a high risk of desynchronization between the backend and frontend. It can also be tedious to create these types repeatedly. -## Usage -Clone the repo from Github. We recommend using [degit](https://www.npmjs.com/package/degit), which downloads the repo without git history. +This is a naive approach, but you get the idea. -```sh -npx degit dimerapp/docs-boilerplate -``` +Tuyau offers an alternative method: a frontend client generated automatically from your AdonisJS API, which will be 100% typesafe without maintaining any types or runtime code yourself. Tuyau uses codegen to detect input and output types for your routes. Taking the same API example, here’s how you could use it with Tuyau: -Install dependencies +```typescript +// In your frontend +import { createTuyau } from '@tuyau/client' +import { api } from '@your-monorepo/my-adonisjs-app/.adonisjs/api' -```sh -cd -npm i -``` - -Run the development server. +export const tuyau = createTuyau({ + api, + baseUrl: 'http://localhost:3333', +}) -```sh -npm run dev +const posts = tuyau.posts.$get({ page: 1, limit: 10 }) +const post = tuyau.posts({ id: 1 }).$get() ``` -And visit [http://localhost:3333/docs/introduction](http://localhost:3333/docs/introduction) URL to view the website in the browser. +Everything in this example is fully typesafe: parameters (like `/posts/:id`), payloads, query params, and responses. You can leverage TypeScript's power to avoid a lot of errors: missing response properties, forgotten request parameters, or typos in payload fields. TypeScript will notify you, and your code won’t compile until it’s corrected. -## Adding content -By default, we create a `docs` collection with an `introduction.md` file inside it. +If you’re familiar with [tRPC](https://trpc.io/docs/client/react), [Elysia Eden](https://elysia.dev/), or [Hono](https://hono.dev/), it’s the same concept. -As a first step, you should open the `content/docs/db.json` file and add all the entries for your documentation. Defining entries by hand may feel tedious at first, but it will allow easier customization in the future. +## How does it work? -A typical database entry has the following properties. +Tuyau uses codegen to generate input and output types for your AdonisJS routes. You’ll need to run the command `node ace tuyau:generate` whenever you add a new route to your project. This will generate a `.adonisjs/api.ts` file containing the following information: -```json -{ - "permalink": "introduction", - "title": "Introduction", - "contentPath": "./introduction.md", - "category": "Guides" -} -``` +- Input types for your routes’ payloads, defined using [VineJS](https://vinejs.dev/). +- Output types for your routes, inferred automatically from your controller methods' return types. +- The route names of your project, allowing you to access them without explicitly defining URLs. -- `permalink`: The unique URL for the doc. The collection prefix will be applied to the permalink automatically. See the `src/collection.ts` file for the collection prefix. -- `title`: The title to display in the sidebar. -- `contentPath`: A relative path to the markdown file. -- `category`: The grouping category for the doc. +For example, in a project like this: -Once you have defined all the entries, create markdown files and write some real content. +```typescript +import { HttpContext } from '@adonisjs/http-server' +import { vine } from 'vinejs' -## Changing website config +export const getPostsValidator = vine.compile( + vine.object({ + page: vine.number().optional(), + limit: vine.number().optional(), + }) +) -We use a very minimal configuration file to update certain website sections. The config is stored inside the `content/config.json` file. +export class PostsController { + public async index({ request }: HttpContext) { + const payload = await request.validateUsing(getPostsValidator) -```json -{ - "links": { - "home": { - "title": "Your project name", - "href": "/" - }, - "github": { - "title": "Your project on Github", - "href": "https://github.com/dimerapp" - } - }, - "fileEditBaseUrl": "https://github.com/dimerapp/docs-boilerplate/blob/develop", - "copyright": "Your project legal name" -} -``` - -- `links`: The object has two fixed links. The homepage and the Github project URL. - -- `fileEditBaseUrl`: The base URL for the file on Github. This is used inside the content footer to display the **Edit on Github** link. - -- `copyright`: The name of display in the Copyright footer. - -- `menu`: Optionally, you can define a header menu as an array of objects. - ```json - { - "menu": [ - { - "href": "/docs/introduction", - "title": "Docs", - }, - { - "href": "https://blog.project.com", - "title": "Blog", - }, - { - "href": "https://github.com/project/releases", - "title": "Releases", - } + return [ + { id: 1, title: 'Hello World' }, + { id: 2, title: 'Hello World 2' }, ] } - ``` - -- `search`: Optionally, you can define config for the Algolia search. - ```json - { - "search": { - "appId": "", - "indexName": "", - "apiKey": "" - } - } - ``` - -## Creating new collections -You may create multiple collections by defining them inside the `src/collections.ts` file. - -A collection is defined using the `Collection` class. The class accepts the URL to the database file. Also, call `collection.boot` once you have configured the collection. - -```ts -// Docs -const docs = new Collection() - .db(new URL('../content/docs/db.json', import.meta.url)) - .useRenderer(renderer) - .urlPrefix('/docs') - -await docs.boot() - -// API reference -const apiReference = new Collection() - .db(new URL('../content/api_reference/db.json', import.meta.url)) - .useRenderer(renderer) - .urlPrefix('/api') - -await apiReference.boot() +} -export const collections = [docs, apiReference] +router.get('/posts', [PostsController, 'index']).as('posts.index') ``` -## Using custom VSCode grammar -You may add custom VSCode languages support by defining them inside the `vscode_grammars/main.ts` file. Each entry must adhere to the `ILanguageRegistration` interface from [Shiki](https://github.com/shikijs/shiki/blob/main/docs/languages.md). - -## Changing the markdown code blocks theme +This would generate a `.adonisjs/api.ts` file that looks like that. This one is highly simplified just to give you an idea : -The code blocks theme is defined using the Markdown renderer instance created inside the `src/bootstrap.ts` file. You can either use one of the [pre-defined themes or a custom theme](https://github.com/dimerapp/shiki/tree/next#using-different-themes). - -```ts -export const renderer = new Renderer(view, pipeline) - .codeBlocksTheme('material-theme-palenight') +```typescript +type Api = { + posts: { + $get: { + input: { + page?: number + limit?: number + } + output: { + id: number + title: string + }[] + } + } +} ``` -## Customizing CSS - -The [base docs theme](https://github.com/dimerapp/docs-theme) makes extensive use of CSS variables, therefore you can tweak most of the styling by defining a new set of variables. - -If you want to change colors, we recommend looking at [Radix Colors](https://www.radix-ui.com/docs/colors/getting-started/installation), because this is what we have used for the default styling. - -## Customizing HTML - -The HTML output is not 100% customizable since we are not creating a generic docs generator for the rest of the world. The boilerplate is meant to be used under constraints. - -However, you can still control the layout, because all sections of the page are exported as Edge component and you can place them anywhere in the DOM. Do check the `templates/docs.edge` file to see how everything is used. - -### Header slots - -You may pass the following component slots to the website header. - -- `logo (required)`: Content for the logo to display on Desktop viewport. +That’s the general idea. We’ll cover more details in the following pages. -- `logoMobile (required)`: Content for the logo to display on Mobile viewport. +## Sponsor -- `popupMenu (optional)`: Define custom markup for the popup menu trigger. The -trigger is displayed in mobile view only. - ```edge - @component('docs::header', contentConfig) - @slots('popMenu') - Open popup menu - @end - @end - ``` +If you like this project, [please consider supporting it by sponsoring](https://github.com/sponsors/Julien-R44/). Your support will help maintain and improve it. Thanks a lot! -- `themeSwitcher (optional)`: Define custom markup for the theme switcher button. - ```edge - @component('docs::header', contentConfig) - @slots('themeSwitcher') - Dark - Light - @end - @end - ``` +## Prior art and inspirations -- `github (optional)`: Define custom markup for the github link in the header. - ```edge - @component('docs::header', contentConfig) - @slots('github') - Github (11K+ Stars) - @end - @end - ``` +- [tRPC](https://trpc.io/docs/client/react) +- [Elysia Eden](https://elysia.dev/) +- [Hono](https://hono.dev/) +- [Ziggy](https://github.com/tighten/ziggy) diff --git a/docs/content/docs/openapi.md b/docs/content/docs/openapi.md new file mode 100644 index 0000000..206509f --- /dev/null +++ b/docs/content/docs/openapi.md @@ -0,0 +1,109 @@ +--- +summary: Generate an OpenAPI specification for your API from the Tuyau codegen. +--- + +# OpenAPI package + +:::warning +OpenAPI package is still experimental and may not work as expected. Please feel free to open an issue if you encounter any problem. + +In fact, I am not sure if this package will be kept in the future. For now, this is just a test to see how far we can go with Tuyau's codegen. +::: + +The `@tuyau/openapi` package allows you to generate an OpenAPI specification for your API from the Tuyau codegen. The specification is generated from the types of routes and validation schemas, using the Typescript compiler and `ts-morph`. Therefore, it has some limitations compared to manually written specifications (whether via decorators, JS doc, YAML files, or Zod schemas describing the inputs/outputs of the routes). + +During development, the specification will be generated on the fly with each request, which can take a bit of time for large APIs with many routes. In production, the specification will be generated at build time into a `.yml` file and the same specification will be served for each request. + +### Installation + +To install the package, you will obviously need to have the `@tuyau/core` package already installed in your AdonisJS project. Then, you can install the `@tuyau/openapi` package: + +```bash +node ace add @tuyau/openapi +``` + +### Usage + +Once the package is configured, you can directly access your API's OpenAPI specification at the `/docs` address. The `/openapi` route is also available to directly access the OpenAPI specification. + +### Customizing the Specification + +To customize the specification, the package exposes `openapi` macros on the routes. These macros allow you to add additional information to the OpenAPI specification. For example, you can add tags, descriptions, responses, parameters, etc. + +```ts +router.group(() => { + router + .get("/random", [MiscController, "index"]) + .openapi({ summary: "Get a random thing" }); + + router + .get("/random/:id", [MiscController, "show"]) + .openapi({ summary: "Get a random thing by id" }); +}) + .prefix("/misc") + .openapi({ tags: ["misc"] }); +``` + +Feel free to use your editor's autocomplete to see all available options. + +Also, from the `config/tuyau.ts` file, you have the ability to customize the OpenAPI specification with the `openapi.documentation` property: + +```ts +const tuyauConfig = defineConfig({ + openapi: { + documentation: { + info: { title: 'My API!', version: '1.0.0', description: 'My super API' }, + tags: [ + { name: 'subscriptions', description: 'Operations about subscriptions' }, + ], + }, + }, +}); +``` + +### Configuration + +The package configuration is done via the `config/tuyau.ts` file, under the `openapi` key. The available options are as follows: + +#### `provider` + +The OpenAPI viewer to use. Two providers are available: `swagger-ui` and `scalar`. By default, the `scalar` provider is used. + +#### `exclude` + +Paths to exclude from the OpenAPI specification. By default, no paths are excluded. You can pass an array of strings or regex. + +Example: + +```ts +const tuyauConfig = defineConfig({ + openapi: { + exclude: ['/health', /admin/] + } +}); +``` + +#### `endpoints` + +The endpoints where the OpenAPI specification and documentation will be available. By default, the OpenAPI specification will be available at `/openapi` and the documentation at `/docs`. + +Example: + +```ts +const tuyauConfig = defineConfig({ + openapi: { + endpoints: { + spec: '/my-super-spec', + ui: '/my-super-doc' + } + } +}); +``` + +#### `scalar` + +The options to pass to the `scalar` provider when used. More details [here](https://github.com/scalar/scalar?tab=readme-ov-file#configuration). + +#### `swagger-ui` + +The options to pass to the `swagger-ui` provider when used. More details [here](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/). diff --git a/docs/src/bootstrap.ts b/docs/src/bootstrap.ts index 4b1b6de..e119fcd 100644 --- a/docs/src/bootstrap.ts +++ b/docs/src/bootstrap.ts @@ -17,8 +17,6 @@ import type { Collection} from '@dimerapp/content'; import { dimer , RenderingPipeline } from '@dimerapp/edge' import { docsHook, docsTheme } from '@dimerapp/docs-theme' -import grammars from '../vscode_grammars/main.js' - type CollectionEntry = Exclude, undefined> edge.use(dimer) @@ -86,8 +84,3 @@ pipeline.use(docsHook).use((node) => { export const renderer = new Renderer(edge, pipeline) .codeBlocksTheme('material-theme-palenight') .useTemplate('docs') - -/** - * Adding grammars - */ -grammars.forEach((grammar) => renderer.registerLanguage(grammar))