@orieljs/framework 0.1.0-alpha.1 → 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +7 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/chunk-7ZQZYDOC.js +3253 -0
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-K6MAA3RM.js → chunk-GVNLZZTN.js} +7 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/chunk-OXAATQA6.js +8433 -0
- package/dist/chunk-OXAATQA6.js.map +1 -0
- package/dist/{chunk-WI7E7Q46.js → chunk-VOYW3R5J.js} +8 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +841 -168
- package/dist/contracts.js +7 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +2916 -5178
- package/dist/index.js +7357 -2609
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-B8J6xq-7.d.ts → jsx-runtime-6um4Mwfb.d.ts} +99 -149
- package/dist/jsx-runtime.d.ts +3 -1
- package/dist/jsx-runtime.js +3 -3
- package/dist/testing-CKwfWJGn.d.ts +1586 -0
- package/dist/testing.d.ts +3 -1408
- package/dist/testing.js +29 -5
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +652 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +35 -30
- package/dist/chunk-AI2RFMBT.js +0 -745
- package/dist/chunk-AI2RFMBT.js.map +0 -1
- package/dist/chunk-BZPCRKFQ.js +0 -2941
- package/dist/chunk-BZPCRKFQ.js.map +0 -1
- package/dist/chunk-K6MAA3RM.js.map +0 -1
- package/dist/chunk-WI7E7Q46.js.map +0 -1
package/docs/routing.md
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
Routes map a URL and an HTTP method to a closure or a controller action. Once `route:types` has run, TypeScript
|
|
4
|
+
checks every link to a named route.
|
|
5
|
+
|
|
6
|
+
## Basic routing
|
|
7
|
+
|
|
8
|
+
A basic route takes a URI and a closure:
|
|
9
|
+
|
|
10
|
+
```ts title="routes/web.ts"
|
|
11
|
+
import { Route } from '@orieljs/framework'
|
|
12
|
+
|
|
13
|
+
Route.get('/greeting', () => 'Hello World')
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A string, a number or a boolean is sent as HTML, `null` and `undefined` as an empty 204 response, and an object, an
|
|
17
|
+
array or a model as JSON. Return a view, a redirect or a `Response` for anything else: [Responses](responses.md) has
|
|
18
|
+
the list.
|
|
19
|
+
|
|
20
|
+
### The default route files
|
|
21
|
+
|
|
22
|
+
Your routes live in the `routes` folder, and `bootstrap/app.ts` names the files to load. `routes/web.ts` is for your
|
|
23
|
+
pages: its routes are in the `web` [middleware group](middleware.md#middleware-groups), which gives them cookies, the
|
|
24
|
+
session and [CSRF protection](csrf.md). The kits with pages split their routes over three files, loaded in this order:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
Application.configure({ basePath: '.' }).withRouting({
|
|
28
|
+
web: ['routes/web.ts', 'routes/settings.ts', 'routes/auth.ts'],
|
|
29
|
+
console: 'routes/console.ts',
|
|
30
|
+
health: '/up',
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> [!WARNING]
|
|
35
|
+
> List every route file in `withRouting()` rather than importing one from another. Each listed file is loaded again
|
|
36
|
+
> for every application your tests create; a file imported from another is loaded once per process, so its routes
|
|
37
|
+
> would be missing from the second application.
|
|
38
|
+
|
|
39
|
+
A route file usually calls the `Route` facade at its top level. It may also export a default function, which is
|
|
40
|
+
called with the router.
|
|
41
|
+
|
|
42
|
+
#### API routes
|
|
43
|
+
|
|
44
|
+
If your application also offers a stateless API, `install:api` adds one:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
node oriel install:api
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It writes `routes/api.ts` and adds it to `withRouting()` in `bootstrap/app.ts`:
|
|
51
|
+
|
|
52
|
+
```ts title="routes/api.ts"
|
|
53
|
+
import { request, Route } from '@orieljs/framework'
|
|
54
|
+
|
|
55
|
+
Route.get('/user', () => request().user()).middleware('auth:token')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The routes in `routes/api.ts` are in the `api` middleware group, which has no session, no cookie encryption and no CSRF
|
|
59
|
+
check, and their URIs start with `/api`. `auth:token` signs the user in from a personal access token, whose table is in
|
|
60
|
+
a new app's migrations ([API tokens](authentication.md#api-tokens) covers them). The API starter kit has its own
|
|
61
|
+
`routes/api.ts`, with its token routes.
|
|
62
|
+
|
|
63
|
+
### Available router methods
|
|
64
|
+
|
|
65
|
+
There is a method for each HTTP verb:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
declare const handler: () => string
|
|
69
|
+
// ---cut---
|
|
70
|
+
Route.get('/posts', handler)
|
|
71
|
+
Route.post('/posts', handler)
|
|
72
|
+
Route.put('/posts/{id}', handler)
|
|
73
|
+
Route.patch('/posts/{id}', handler)
|
|
74
|
+
Route.delete('/posts/{id}', handler)
|
|
75
|
+
Route.options('/posts', handler)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`match()` takes several verbs, and `any()` answers them all:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
declare const handler: () => string
|
|
82
|
+
// ---cut---
|
|
83
|
+
Route.match(['get', 'post'], '/search', handler)
|
|
84
|
+
Route.any('/webhook', handler)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A `GET` route answers `HEAD` too, with the same status and headers and no body. A request whose method the URI
|
|
88
|
+
doesn't accept gets a 405 with an `Allow` header listing the methods it does, and an `OPTIONS` request to a URI with
|
|
89
|
+
no `OPTIONS` route gets a 200 with that header.
|
|
90
|
+
|
|
91
|
+
> [!NOTE]
|
|
92
|
+
> Laravel tries routes in the order you define them, so `/posts/create` has to come before `/posts/{post}`. Oriel
|
|
93
|
+
> tries the most specific route first, so the order of your routes doesn't decide which one answers: segment by
|
|
94
|
+
> segment, fixed text before a parameter, and a parameter before an optional one. Routes of the same shape keep the
|
|
95
|
+
> order you defined them in, and fallback routes always come last.
|
|
96
|
+
|
|
97
|
+
#### Dependency injection
|
|
98
|
+
|
|
99
|
+
A route's closure receives its route parameters and nothing else. The current request comes from the `request()`
|
|
100
|
+
helper, which works anywhere in your code while a request is handled:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
Route.get('/users', () => {
|
|
104
|
+
const page = request().integer('page', 1)
|
|
105
|
+
|
|
106
|
+
return User.query().forPage(page).get()
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
> [!NOTE]
|
|
111
|
+
> Laravel injects the request, and any class you type-hint, into a route's closure. TypeScript types are gone when
|
|
112
|
+
> your app runs, so Oriel can't see them: it passes only the route parameters. Take the request from `request()`,
|
|
113
|
+
> and a service from `inject()` or its [facade](facades.md). [Requests](requests.md) covers the request, and
|
|
114
|
+
> [Injecting with `inject()`](container.md#injecting-with-inject) the services.
|
|
115
|
+
|
|
116
|
+
#### CSRF protection
|
|
117
|
+
|
|
118
|
+
An HTML form that posts to a route in the `web` group must send the CSRF token, or the request is refused with a
|
|
119
|
+
419. `csrfField()` writes the hidden field:
|
|
120
|
+
|
|
121
|
+
```tsx title="resources/views/profile.tsx"
|
|
122
|
+
import { csrfField } from '@orieljs/framework'
|
|
123
|
+
|
|
124
|
+
export default function Profile() {
|
|
125
|
+
return (
|
|
126
|
+
<form method="post" action="/profile">
|
|
127
|
+
{csrfField()}
|
|
128
|
+
<button type="submit">Save</button>
|
|
129
|
+
</form>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
[CSRF protection](csrf.md) explains the token and the other ways to send it.
|
|
135
|
+
|
|
136
|
+
### Redirect routes
|
|
137
|
+
|
|
138
|
+
A route that only redirects needs no closure. `Route.redirect()` answers every verb with a 302, or the status you
|
|
139
|
+
give it, and `Route.permanentRedirect()` with a 301:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
Route.redirect('/here', '/there')
|
|
143
|
+
Route.redirect('/here', '/there', 301)
|
|
144
|
+
Route.permanentRedirect('/old-blog', '/blog')
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The target may use the route's parameters: `Route.redirect('/posts/{id}', '/articles/{id}')`.
|
|
148
|
+
|
|
149
|
+
### View routes
|
|
150
|
+
|
|
151
|
+
`Route.view()` shows a view, with the data you give it and the route's parameters:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
Route.view('/welcome', 'welcome')
|
|
155
|
+
Route.view('/welcome', 'welcome', { name: 'Taylor' })
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`Route.inertia()` does the same for an Inertia page, with its props:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
Route.inertia('/settings/appearance', 'Settings/Appearance')
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Resource routes
|
|
165
|
+
|
|
166
|
+
`Route.resource()` registers the seven routes of a resource controller in one line, and `Route.apiResource()` the
|
|
167
|
+
five an API needs, without the pages for its forms:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
declare class PhotoController {}
|
|
171
|
+
// ---cut---
|
|
172
|
+
Route.resource('photos', PhotoController)
|
|
173
|
+
Route.apiResource('photos', PhotoController)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
[Controllers](controllers.md#resource-controllers) lists their URIs, names and options.
|
|
177
|
+
|
|
178
|
+
### Listing your routes
|
|
179
|
+
|
|
180
|
+
`route:list` shows every route with its methods, URI, name and action, sorted by URI:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
node oriel route:list
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Filter it by method, by part of the name or by part of the URI, or leave out the URIs that contain a text:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
node oriel route:list --method=post
|
|
190
|
+
node oriel route:list --name=posts
|
|
191
|
+
node oriel route:list --path=admin --except-path=admin/reports
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`--reverse` reverses the order, and `--json` prints the routes as JSON, with each route's middleware.
|
|
195
|
+
|
|
196
|
+
### Routing customization
|
|
197
|
+
|
|
198
|
+
`withRouting()` in `bootstrap/app.ts` loads your routes once every service provider has booted, so a pattern or a
|
|
199
|
+
binding you set in a [provider's](providers.md) `boot()` is in place when your route files run:
|
|
200
|
+
|
|
201
|
+
```ts title="bootstrap/app.ts"
|
|
202
|
+
import { dirname } from 'node:path'
|
|
203
|
+
import { Application } from '@orieljs/framework'
|
|
204
|
+
import providers from './providers'
|
|
205
|
+
|
|
206
|
+
export function createApplication(): Promise<Application> {
|
|
207
|
+
return Application.configure({ basePath: dirname(import.meta.dirname) })
|
|
208
|
+
.withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })
|
|
209
|
+
.withProviders(providers)
|
|
210
|
+
.create()
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`web` and `api` take one file or a list, `console` names the file of your closure commands, and `health` the URI of
|
|
215
|
+
the health check page, which answers 200 when your app is up. `apiPrefix` changes the API routes' prefix from `api`:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
Application.configure({ basePath: '.' }).withRouting({ api: 'routes/api.ts', apiPrefix: 'api/admin' })
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Route parameters
|
|
222
|
+
|
|
223
|
+
### Required parameters
|
|
224
|
+
|
|
225
|
+
Capture a segment of the URI with braces:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
Route.get('/user/{id}', (id: string) => `User ${id}`)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A route may have as many parameters as it needs. The action receives them in the order they appear in the URI, so
|
|
232
|
+
the names in your function don't have to match:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
Route.get('/teams/{team}/members/{member}', (teamId: string, memberId: string) => {
|
|
236
|
+
return { teamId, memberId }
|
|
237
|
+
})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A parameter's name is letters, digits and underscores. A parameter named after one of your models receives the model
|
|
241
|
+
rather than the text (see [Route model binding](#route-model-binding)), so `{team}` stays a string only while your
|
|
242
|
+
app has no `Team` model.
|
|
243
|
+
|
|
244
|
+
#### Parameters and dependency injection
|
|
245
|
+
|
|
246
|
+
Nothing but the route's parameters is passed to the action, whatever else it needs. Read the rest from the request:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
Route.get('/user/{id}', (id: string) => {
|
|
250
|
+
const tab = request().query('tab', 'profile')
|
|
251
|
+
|
|
252
|
+
return `User ${id}, ${tab}`
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Optional parameters
|
|
257
|
+
|
|
258
|
+
Add a `?` to make a parameter optional. When the segment is missing, the parameter is `undefined`, so give it a
|
|
259
|
+
default:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
Route.get('/user/{name?}', (name?: string) => name ?? 'Guest')
|
|
263
|
+
|
|
264
|
+
Route.get('/user/{name?}', (name = 'John') => name)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Regular expression constraints
|
|
268
|
+
|
|
269
|
+
`where()` constrains a parameter with a regular expression. A URI whose segment doesn't match gets a 404:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
Route.get('/user/{name}', (name: string) => name).where('name', '[A-Za-z]+')
|
|
273
|
+
|
|
274
|
+
Route.get('/user/{id}', (id: string) => id).where('id', '[0-9]+')
|
|
275
|
+
|
|
276
|
+
Route.get('/user/{id}/{name}', (id: string, name: string) => ({ id, name })).where({ id: '[0-9]+', name: '[a-z]+' })
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The common patterns have their own methods, which take one parameter or a list:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
Route.get('/user/{id}/{name}', (id: string, name: string) => ({ id, name }))
|
|
283
|
+
.whereNumber('id')
|
|
284
|
+
.whereAlpha('name')
|
|
285
|
+
|
|
286
|
+
Route.get('/user/{name}', (name: string) => name).whereAlphaNumeric('name')
|
|
287
|
+
Route.get('/user/{id}', (id: string) => id).whereUuid('id')
|
|
288
|
+
Route.get('/user/{id}', (id: string) => id).whereUlid('id')
|
|
289
|
+
Route.get('/category/{category}', (category: string) => category).whereIn('category', ['movie', 'song', 'painting'])
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`whereIn()` matches each value as written, so a value such as `c++` isn't read as a pattern.
|
|
293
|
+
|
|
294
|
+
#### Global constraints
|
|
295
|
+
|
|
296
|
+
A pattern that every route should share goes in a [service provider's](providers.md#the-boot-method) `boot()`, with
|
|
297
|
+
`Route.pattern()`:
|
|
298
|
+
|
|
299
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
300
|
+
import { Route, ServiceProvider } from '@orieljs/framework'
|
|
301
|
+
|
|
302
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
303
|
+
override boot(): void {
|
|
304
|
+
Route.pattern('id', '[0-9]+')
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
From then on, every `{id}` must be a number. A route's own `where()` wins over the global pattern.
|
|
310
|
+
|
|
311
|
+
#### Encoded forward slashes
|
|
312
|
+
|
|
313
|
+
A parameter matches anything but a `/`. To let it match the rest of the path, slashes included, constrain it with
|
|
314
|
+
`.*`:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
Route.get('/search/{search}', (search: string) => search).where('search', '.*')
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Parameters arrive decoded: `/search/a%2Fb` gives `a/b`.
|
|
321
|
+
|
|
322
|
+
## Named routes
|
|
323
|
+
|
|
324
|
+
A name lets you build a route's URL, or redirect to it, without writing the URI again:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
Route.get('/user/profile', () => view('profile')).name('profile')
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Controller actions take names the same way:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
Route.get('/posts/{post}', [PostController, 'show']).name('posts.show')
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
> [!WARNING]
|
|
337
|
+
> Give each route its own name. When two routes share one, the last one defined wins.
|
|
338
|
+
|
|
339
|
+
#### Generating URLs to named routes
|
|
340
|
+
|
|
341
|
+
`route()` builds the URL of a named route, and `redirect().route()` a redirect to it:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
const url = route('profile')
|
|
345
|
+
|
|
346
|
+
const response = redirect().route('profile')
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
A route's parameters go in an object. Any key the route doesn't use is added to the query string:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
Route.get('/user/{id}/profile', (id: string) => id).name('profile')
|
|
353
|
+
|
|
354
|
+
route('profile', { id: 1, photos: 'yes' })
|
|
355
|
+
// http://localhost:8000/user/1/profile?photos=yes
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
You can also pass a single value for a route's first parameter, or a list of values in the URI's order. A model
|
|
359
|
+
fills a parameter with its route key, or with the column a `{post:slug}` parameter names:
|
|
360
|
+
|
|
361
|
+
```ts
|
|
362
|
+
declare const post: Post
|
|
363
|
+
// ---cut---
|
|
364
|
+
route('posts.show', post)
|
|
365
|
+
route('posts.show', [post])
|
|
366
|
+
route('posts.show', { post, page: 2 })
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`route()` returns an absolute URL on the current request's host, or on `app.url` outside a request. Pass `false` as
|
|
370
|
+
its third argument for a path:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
declare const post: Post
|
|
374
|
+
// ---cut---
|
|
375
|
+
route('posts.show', post, false) // /posts/1
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
An optional parameter you leave out is dropped from the URL. A required one is an error: "Missing required parameter
|
|
379
|
+
for [Route: posts.show] [URI: posts/{post}] [Missing parameter: post].".
|
|
380
|
+
|
|
381
|
+
#### Typed route parameters
|
|
382
|
+
|
|
383
|
+
`route:types` reads your routes and writes their names and parameters for TypeScript:
|
|
384
|
+
|
|
385
|
+
```sh
|
|
386
|
+
node oriel route:types
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
From then on, `route()` and `redirect().route()` accept only names that exist, and want the parameters each route
|
|
390
|
+
defines: a renamed route or a missing parameter fails the type check everywhere it is linked to.
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
declare const post: Post
|
|
394
|
+
// @filename: types/routes.d.ts
|
|
395
|
+
declare module '@orieljs/framework' {
|
|
396
|
+
interface RouteList {
|
|
397
|
+
'posts.show': { post: unknown }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
declare module '@orieljs/framework/contracts' {
|
|
401
|
+
interface RouteList {
|
|
402
|
+
'posts.show': { post: unknown }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
export {}
|
|
406
|
+
// ---cut---
|
|
407
|
+
route('posts.show', post)
|
|
408
|
+
route('posts.show', { post: post.id, page: 2 })
|
|
409
|
+
|
|
410
|
+
// @ts-expect-error: no route is named post.show
|
|
411
|
+
route('post.show', post)
|
|
412
|
+
|
|
413
|
+
// @ts-expect-error: the route needs its post
|
|
414
|
+
route('posts.show')
|
|
415
|
+
|
|
416
|
+
// @ts-expect-error: getKey() is unknown; pass the model or post.id
|
|
417
|
+
route('posts.show', { post: post.getKey() })
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A parameter takes a string, a number, a boolean or a model. A model's `getKey()` is typed `unknown`, so pass the
|
|
421
|
+
model itself or its typed `id`.
|
|
422
|
+
|
|
423
|
+
`route:types` writes two files, both ignored by git: `types/routes.d.ts` for your server code, and
|
|
424
|
+
`resources/js/oriel/routes.ts` for your pages. `--server` and `--output` write them somewhere else. The Inertia and
|
|
425
|
+
API starter kits run it in `npm run typecheck`. The Inertia kits' Vite plugin also runs it when Vite starts, and again
|
|
426
|
+
whenever a file in `routes/` or `app/Http/Controllers/` changes while `npm run dev` runs. In the Live kit, run it
|
|
427
|
+
yourself. Until the first run, any name is accepted.
|
|
428
|
+
|
|
429
|
+
#### Route URLs in your pages
|
|
430
|
+
|
|
431
|
+
Your [Inertia](inertia.md#links-and-the-route-helper) pages build URLs with the same `route()`, from your adapter. It
|
|
432
|
+
takes the same names and parameters, and returns a path. The browser's route table leaves out the routes that
|
|
433
|
+
`types.except_routes` in `config/inertia.ts` names, with `*` as a wildcard. Unless you set it, it leaves out `live.*`,
|
|
434
|
+
Live's own routes.
|
|
435
|
+
|
|
436
|
+
```tsx tab="React" title="resources/js/components/PostLink.tsx"
|
|
437
|
+
import { Link, route } from '@orieljs/inertia-react'
|
|
438
|
+
|
|
439
|
+
export function PostLink({ id, title }: { id: number; title: string }) {
|
|
440
|
+
return <Link href={route('posts.show', { post: id })}>{title}</Link>
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
```vue tab="Vue" title="resources/js/components/PostLink.vue"
|
|
444
|
+
<script setup lang="ts">
|
|
445
|
+
import { Link, route } from '@orieljs/inertia-vue'
|
|
446
|
+
|
|
447
|
+
defineProps<{ id: number; title: string }>()
|
|
448
|
+
</script>
|
|
449
|
+
|
|
450
|
+
<template>
|
|
451
|
+
<Link :href="route('posts.show', { post: id })">{{ title }}</Link>
|
|
452
|
+
</template>
|
|
453
|
+
```
|
|
454
|
+
```svelte tab="Svelte" title="resources/js/components/PostLink.svelte"
|
|
455
|
+
<script lang="ts">
|
|
456
|
+
import { Link, route } from '@orieljs/inertia-svelte'
|
|
457
|
+
|
|
458
|
+
let { id, title }: { id: number; title: string } = $props()
|
|
459
|
+
</script>
|
|
460
|
+
|
|
461
|
+
<Link href={route('posts.show', { post: id })}>{title}</Link>
|
|
462
|
+
```
|
|
463
|
+
```tsx tab="Solid" title="resources/js/components/PostLink.tsx"
|
|
464
|
+
import { Link, route } from '@orieljs/inertia-solid'
|
|
465
|
+
|
|
466
|
+
export function PostLink(props: { id: number; title: string }) {
|
|
467
|
+
return <Link href={route('posts.show', { post: props.id })}>{props.title}</Link>
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
```ts tab="Angular" title="resources/js/components/PostLink.component.ts"
|
|
471
|
+
import { Component, computed, input } from '@angular/core'
|
|
472
|
+
import { Link, route } from '@orieljs/inertia-angular'
|
|
473
|
+
|
|
474
|
+
@Component({
|
|
475
|
+
selector: 'app-post-link',
|
|
476
|
+
imports: [Link],
|
|
477
|
+
template: `<a oLink [href]="href()">{{ title() }}</a>`,
|
|
478
|
+
})
|
|
479
|
+
export class PostLink {
|
|
480
|
+
readonly id = input.required<number>()
|
|
481
|
+
readonly title = input.required<string>()
|
|
482
|
+
readonly href = computed(() => route('posts.show', { post: this.id() }))
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
#### Inspecting the current route
|
|
487
|
+
|
|
488
|
+
`request().routeIs()` tells you whether the current request matched a route of that name. It takes several names,
|
|
489
|
+
and `*` as a wildcard:
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
if (request().routeIs('admin.*')) {
|
|
493
|
+
// ...
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Route groups
|
|
498
|
+
|
|
499
|
+
A group shares middleware, a controller, a URI prefix or a name prefix between its routes. Groups can be nested: a
|
|
500
|
+
nested group adds its middleware after its parent's, and its prefixes after its parent's prefixes.
|
|
501
|
+
|
|
502
|
+
### Middleware
|
|
503
|
+
|
|
504
|
+
`middleware()` before `group()` gives every route in the group its middleware, which run in the order you list them,
|
|
505
|
+
except that `auth` and `guest` run first ([Sorting middleware](middleware.md#sorting-middleware) has the rule):
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
Route.middleware(['auth', 'verified']).group(() => {
|
|
509
|
+
Route.get('/dashboard', () => view('dashboard')).name('dashboard')
|
|
510
|
+
Route.get('/settings', () => view('settings')).name('settings')
|
|
511
|
+
})
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
For a single route, chain the route's own `middleware()`: `Route.get('/profile', handler).middleware('auth')`.
|
|
515
|
+
|
|
516
|
+
### Controllers
|
|
517
|
+
|
|
518
|
+
When every route of a group calls the same [controller](controllers.md), name it once with `controller()`. Each route
|
|
519
|
+
then gives only the method's name:
|
|
520
|
+
|
|
521
|
+
```ts title="routes/web.ts"
|
|
522
|
+
import { Route } from '@orieljs/framework'
|
|
523
|
+
import { PostController } from '../app/Http/Controllers/PostController'
|
|
524
|
+
|
|
525
|
+
Route.controller(PostController).group(() => {
|
|
526
|
+
Route.get('/posts', 'index')
|
|
527
|
+
Route.get('/posts/{post}', 'show')
|
|
528
|
+
Route.post('/posts', 'store')
|
|
529
|
+
})
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Inside a controller group, the method's name is a plain string, which TypeScript doesn't check: a misspelt one is a
|
|
533
|
+
500 ("Method PostController::shwo does not exist.") when the route is called. Outside a group, the
|
|
534
|
+
`[PostController, 'index']` form is checked.
|
|
535
|
+
|
|
536
|
+
### Route prefixes
|
|
537
|
+
|
|
538
|
+
`prefix()` adds a segment in front of each URI in the group:
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
Route.prefix('admin').group(() => {
|
|
542
|
+
Route.get('/users', () => 'Users') // matches /admin/users
|
|
543
|
+
})
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Route name prefixes
|
|
547
|
+
|
|
548
|
+
`name()` adds a prefix to each route's name. It is added as written, so end it with a `.`:
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
Route.name('admin.').group(() => {
|
|
552
|
+
Route.get('/users', () => 'Users').name('users') // named admin.users
|
|
553
|
+
})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
## Route model binding
|
|
557
|
+
|
|
558
|
+
A route often looks a model up by the ID in its URI. Route model binding does it for you: the action receives the
|
|
559
|
+
model rather than the ID.
|
|
560
|
+
|
|
561
|
+
### Implicit binding
|
|
562
|
+
|
|
563
|
+
When a parameter is named after one of your models, its segment is that model's route key. `{user}` finds the
|
|
564
|
+
`User` whose `id` matches the segment, or answers 404 before your code runs:
|
|
565
|
+
|
|
566
|
+
```ts title="routes/web.ts"
|
|
567
|
+
import { Route } from '@orieljs/framework'
|
|
568
|
+
import type { User } from '../app/Models/User'
|
|
569
|
+
|
|
570
|
+
Route.get('/users/{user}', (user: User) => user.email)
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
The name is matched to a model in `app/Models` by its StudlyCase form: `{post}` is `Post`, `{blog_post}` and
|
|
574
|
+
`{blogPost}` are `BlogPost`. A parameter that names no model stays a string.
|
|
575
|
+
|
|
576
|
+
> [!NOTE]
|
|
577
|
+
> Laravel finds the model from the parameter's type hint. TypeScript types are gone when your app runs, so Oriel finds
|
|
578
|
+
> it from the parameter's name in the URI.
|
|
579
|
+
|
|
580
|
+
Binding works the same for controller actions:
|
|
581
|
+
|
|
582
|
+
```ts title="app/Http/Controllers/PostController.ts"
|
|
583
|
+
import { view } from '@orieljs/framework'
|
|
584
|
+
import type { Post } from '../../Models/Post'
|
|
585
|
+
import { Controller } from './Controller'
|
|
586
|
+
|
|
587
|
+
export class PostController extends Controller {
|
|
588
|
+
show(post: Post) {
|
|
589
|
+
return view('posts.show', { post })
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### Soft deleted models
|
|
595
|
+
|
|
596
|
+
A soft deleted model isn't found, so its route answers 404. There is no `withTrashed()` on routes.
|
|
597
|
+
|
|
598
|
+
#### Customizing the key
|
|
599
|
+
|
|
600
|
+
Name a column after a colon to look the model up by that column:
|
|
601
|
+
|
|
602
|
+
```ts
|
|
603
|
+
Route.get('/posts/{post:slug}', (post: Post) => view('posts.show', { post }))
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
To use a column for every route of a model, override `getRouteKeyName()` on the model. `route()` then fills the
|
|
607
|
+
parameter from that column too:
|
|
608
|
+
|
|
609
|
+
```ts title="app/Models/Post.ts"
|
|
610
|
+
import { Model } from '@orieljs/framework'
|
|
611
|
+
|
|
612
|
+
export class Post extends Model {
|
|
613
|
+
override getRouteKeyName(): string {
|
|
614
|
+
return 'slug'
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Explicit binding
|
|
620
|
+
|
|
621
|
+
A parameter that isn't named after its model is bound with `Route.model()`, in a [service
|
|
622
|
+
provider's](providers.md#the-boot-method) `boot()`:
|
|
623
|
+
|
|
624
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
625
|
+
import { Route, ServiceProvider } from '@orieljs/framework'
|
|
626
|
+
import { User } from '../Models/User'
|
|
627
|
+
|
|
628
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
629
|
+
override boot(): void {
|
|
630
|
+
Route.model('author', User)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
Every `{author}` parameter is now a `User`, found by its route key, and a missing one is a 404:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
Route.get('/posts/by/{author}', (author: User) => author.posts().get())
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
#### Customizing the resolution logic
|
|
642
|
+
|
|
643
|
+
`Route.bind()` resolves a parameter however you like. The callback receives the segment, and the action receives
|
|
644
|
+
what it returns:
|
|
645
|
+
|
|
646
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
647
|
+
import { Route, ServiceProvider } from '@orieljs/framework'
|
|
648
|
+
import { User } from '../Models/User'
|
|
649
|
+
|
|
650
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
651
|
+
override boot(): void {
|
|
652
|
+
Route.bind('member', (value) => User.where('name', value).firstOrFail())
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
A `Route.bind()` callback comes first, then `Route.model()`, then binding by name. To change how a model is found on
|
|
658
|
+
every route, override the model's `resolveRouteBinding(value, field)`, which returns the model or `undefined`, or its
|
|
659
|
+
`resolveRouteBindingQuery(query, value, field)`, which returns the query that finds it. Inside that override,
|
|
660
|
+
TypeScript can't tell which columns the model has, so every column name is a type error there: a condition such as
|
|
661
|
+
`whereNotNull('published_at')` runs, but doesn't type-check.
|
|
662
|
+
|
|
663
|
+
## Fallback routes
|
|
664
|
+
|
|
665
|
+
`Route.fallback()` answers any `GET` request that no other route matched, in place of the 404 page. It is always
|
|
666
|
+
tried last, wherever you define it, and when it sits in `routes/web.ts`, the `web` middleware run for it:
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
Route.fallback(() => view('errors.not-found'))
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Inside a group with a prefix, a fallback answers only the URIs under that prefix. A `POST` to an unknown URI is still
|
|
673
|
+
a 404.
|
|
674
|
+
|
|
675
|
+
## Rate limiting
|
|
676
|
+
|
|
677
|
+
### Attaching rate limiters to routes
|
|
678
|
+
|
|
679
|
+
The `throttle` middleware limits how often a client may call a route. Its parameters are the number of requests and
|
|
680
|
+
the window in minutes:
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
declare const handler: () => string
|
|
684
|
+
// ---cut---
|
|
685
|
+
Route.middleware('throttle:60,1').group(() => {
|
|
686
|
+
Route.post('/audio', handler)
|
|
687
|
+
Route.post('/video', handler)
|
|
688
|
+
})
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
It counts per signed-in user, on any route, and per IP address for a guest. The counts are kept in your
|
|
692
|
+
cache's default store, or in the store that a `limiter` key in `config/cache.ts` names. Each response carries
|
|
693
|
+
`X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. Past the limit, the route answers 429 ("Too Many Attempts.")
|
|
694
|
+
with `Retry-After`, the seconds to wait, and `X-RateLimit-Reset`, the Unix time the window ends.
|
|
695
|
+
|
|
696
|
+
Every route behind `throttle` adds to the same count for a client, whatever its numbers. To count a group of routes
|
|
697
|
+
on its own, give `throttle` a third parameter, a prefix for its key: `throttle:10,1,uploads`.
|
|
698
|
+
|
|
699
|
+
> [!WARNING]
|
|
700
|
+
> Laravel's named rate limiters (`RateLimiter::for()`) aren't in Oriel: give `throttle` its numbers directly.
|
|
701
|
+
|
|
702
|
+
## Form method spoofing
|
|
703
|
+
|
|
704
|
+
HTML forms can only send `GET` and `POST`. To reach a `PUT`, `PATCH` or `DELETE` route from a form, post it with a
|
|
705
|
+
hidden `_method` field, which `methodField()` writes:
|
|
706
|
+
|
|
707
|
+
```tsx title="resources/views/posts/edit.tsx"
|
|
708
|
+
import { csrfField, methodField } from '@orieljs/framework'
|
|
709
|
+
import type { Post } from '../../../app/Models/Post'
|
|
710
|
+
|
|
711
|
+
export default function Edit({ post }: { post: Post }) {
|
|
712
|
+
return (
|
|
713
|
+
<form method="post" action={`/posts/${post.id}`}>
|
|
714
|
+
{methodField('PUT')}
|
|
715
|
+
{csrfField()}
|
|
716
|
+
<button type="submit">Update</button>
|
|
717
|
+
</form>
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
The `X-HTTP-Method-Override` header does the same for a script. Only a `POST` can be spoofed, and never as `GET` or
|
|
723
|
+
`HEAD`.
|
|
724
|
+
|
|
725
|
+
## Accessing the current route
|
|
726
|
+
|
|
727
|
+
The `Route` [facade](facades.md) tells you which route is handling the current request, and the request gives you its
|
|
728
|
+
parameters:
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
const current = Route.current() // the route, or undefined outside a request
|
|
732
|
+
const name = Route.currentRouteName() // 'posts.show'
|
|
733
|
+
const post = request().route('post') // the bound Post
|
|
734
|
+
const parameters = request().route() // every parameter, by name
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
`Route.has('login')` tells you whether a route of that name exists, as a new app's welcome page does to show its login
|
|
738
|
+
link.
|