Client
As mentioned in the installation, we used createTuyau
to create our client instance in our frontend application. This instance will be used to make requests to our API, and it will also provide some helpers to generate URLs, check the current route, etc.
Tuyau client is built on top of Ky.
Options
The createTuyau
function accepts an object with the following 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.
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:
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.
export const tuyau = createTuyau({
api,
baseUrl: 'http://localhost:3333',
})
Other options
The Tuyau client is built on top of Ky. So you can pass any options supported by Ky. Here's an example with some options:
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:
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:
// 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:
// 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:
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:
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 asids[]=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:
<input type="file" id="file" />
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
package.
If you're using React Native, pass your file as follows:
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 2xxerror
: The error data if the status is 3xxstatus
: The response's status coderesponse
: 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:
// 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: '[email protected]', 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.
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:
import type { InferResponseType, InferErrorType, InferRequestType } from '@tuyau/client';
// InferRequestType
type LoginRequest = InferRequestType<typeof tuyau.login.post>;
// InferResponseType
type LoginResponse = InferResponseType<typeof tuyau.login.post>;
// InferErrorType
type LoginError = InferErrorType<typeof tuyau.login.post>;
Generating URL
If you need to generate the URL of a route without making the request, you can use the $url
method:
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 works:
// 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:
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:
export function MyComponent() {
return (
<div>
<a href={route('users.posts', { id: 1, postId: 2 })}>Go to post</a>
</div>
)
}
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:
// 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:
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:
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
:
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.
FAQ
Why do I get a ModelObject
type when returning a Lucid Model ?
If you try returning an instance of a Lucid model, you'll quickly realize that you don’t get any type information. Instead, you’ll see ModelObject
. The reason is simple: Lucid is not typesafe. As a result, Tuyau cannot infer the return type correctly. There are several solutions to address this issue:
- Help TypeScript by adding a type cast.
- Use DTOs to map the data. This is likely the best approach since, beyond providing type safety, DTOs offer other benefits.
class UsersController {
async edit({ inertia, params }: HttpContext) {
const user = users.serialize() as {
id: number
name: string
}
return { user }
}
}
class UserDto {
constructor(private user: User) {}
toJson() {
return {
id: this.user.id,
name: this.user.name
}
}
}
class UsersController {
async edit({ inertia, params }: HttpContext) {
const user = await User.findOrFail(params.id)
return { user: new UserDto(user).toJson() }
}
}