Skip to content

Commit 6d3979d

Browse files
committed
Update docs on adding/changing api resources
1 parent 1593fec commit 6d3979d

1 file changed

Lines changed: 162 additions & 5 deletions

File tree

doc/Developing/Creating-API-Resources.md

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ class LocationRepository extends Repository
6666

6767
- **`$model`** The Eloquent model class this repository wraps.
6868
- **`$id`** Override if the model uses a non-standard primary key (e.g. `device_id`, `port_id`).
69-
- **`fields()`** Defines which attributes are exposed in the API response. Use `->readonly()` for fields that should not be writable.
70-
- **`$search`** Array of columns that the `?search=` parameter queries against.
69+
- **`uriKey`** The URL slug for this resource (e.g. `devices`). Auto-derived from the class name; override to control the public name. See [Resource name & URLs](#resource-name--urls-urikey).
70+
- **`$title`** The column used as the human-readable label for a record (shown in relationship pickers and some UIs).
71+
- **`fields()`** Defines which attributes are exposed in the API response. Use `->readonly()` for fields that should not be writable. See [Fields and `->label()`](#fields-and-the-label-method).
72+
- **`$search` / `searchables()`** Columns the `?search=` parameter queries against. See [Filtering, searching, sorting](#filtering-searching-and-sorting).
73+
- **`related()`** Declares relationships (nested routes + `?include=`). See [Relationships](#relationships-related).
7174
- **`indexQuery()` / `showQuery()`** Scope queries for access control. See existing `DeviceRepository` and `PortRepository` for examples using `$query->hasAccess($user)`.
7275

7376
### Making fields writable
@@ -79,6 +82,155 @@ field('notes')->rules('nullable', 'string', 'max:1000'),
7982
field('purpose')->rules('nullable', 'string', 'max:255'),
8083
```
8184

85+
### Fields and the `->label()` method
86+
87+
`field('name')` exposes an attribute. The first argument is the **public JSON key** in the response. When it matches the column name, the value is read/written directly:
88+
89+
```php
90+
field('hostname')->rules('required', 'string', 'max:255'), // reads/writes the hostname column
91+
```
92+
93+
When the API name should differ from the database column, pass a **resolve callback** as the second argument (this is the convention used throughout LibreNMS to present clean names over legacy columns):
94+
95+
```php
96+
field('systemName', fn ($value, $model) => $model->sysName)->readonly(), // JSON "systemName" <- sysName column
97+
field('isUp', fn ($value, $model) => $model->status)->readonly(),
98+
```
99+
100+
Common field modifiers:
101+
102+
| Modifier | Effect |
103+
|---|---|
104+
| `->readonly()` | Field is returned but never accepted on create/update. |
105+
| `->rules('nullable', 'string', ...)` | Laravel validation applied on store/update. |
106+
| `->hidden()` | Field is accepted for writes but omitted from responses. |
107+
| `->label('name')` | Overrides the field's "attribute" used internally as `getAttribute() = label ?? attribute`. Rarely needed on plain fields; its important role is on **relationships**, where it sets the relationship's URL segment. |
108+
109+
> On **relationships** (`HasMany`/`BelongsTo`/`BelongsToMany`) the `label` is what controls the nested-route URL segment but you normally don't set it by hand: the base repository derives it automatically. See [Relationships](#relationships-related).
110+
111+
## Filtering, searching, and sorting
112+
113+
These three methods turn into query parameters that show up automatically in the OpenAPI document and Swagger UI. In every case the **array key is the public API name** and `setColumn()` maps it to the real database column, so the API stays stable even if the schema changes.
114+
115+
```php
116+
use Binaryk\LaravelRestify\Filters\SearchableFilter;
117+
use Binaryk\LaravelRestify\Filters\MatchFilter;
118+
use Binaryk\LaravelRestify\Filters\SortableFilter;
119+
120+
// ?search=core fuzzy match across these columns
121+
public static function searchables(): array
122+
{
123+
return [
124+
'hostname' => SearchableFilter::make()->setColumn('hostname'),
125+
'ip' => SearchableFilter::make()->setColumn('ip'),
126+
];
127+
}
128+
129+
// ?hostname=core1&isUp=true exact, per-field filters
130+
public static function matches(): array
131+
{
132+
return [
133+
'hostname' => MatchFilter::make()->setType('text')->setColumn('hostname'),
134+
'isUp' => MatchFilter::make()->setType('bool')->setColumn('status'),
135+
'uptime' => MatchFilter::make()->setType('integer')->setColumn('uptime'),
136+
];
137+
}
138+
139+
// ?sort=-hostname (leading "-" = descending)
140+
public static function sorts(): array
141+
{
142+
return [
143+
'hostname' => SortableFilter::make()->setColumn('hostname'),
144+
];
145+
}
146+
```
147+
148+
- **`setColumn()`** decouples the API name (the array key) from the DB column. e.g. expose `systemName` while filtering on `sysName`.
149+
- **`MatchFilter::setType()`** (`text`, `bool`, `integer`, `datetime`, …) drives both how the value is cast in the query and the parameter's type in the OpenAPI document.
150+
- A simple `public static array $search = ['hostname', 'ip'];` is a shorthand alternative to `searchables()` when you only need plain column matching.
151+
152+
## Relationships (`related()`)
153+
154+
Declare related resources in `related()`. Each entry maps an array key to an *eager field* pointing at another repository:
155+
156+
```php
157+
use Binaryk\LaravelRestify\Fields\BelongsTo;
158+
use Binaryk\LaravelRestify\Fields\HasMany;
159+
use Binaryk\LaravelRestify\Fields\BelongsToMany;
160+
161+
public static function related(): array
162+
{
163+
return [
164+
'ports' => HasMany::make('ports', PortRepository::class),
165+
'location' => BelongsTo::make('location', LocationRepository::class),
166+
'bills' => BelongsToMany::make('bills', BillRepository::class),
167+
];
168+
}
169+
```
170+
171+
Field types:
172+
173+
| Type | Use when | Extra routes |
174+
|---|---|---|
175+
| `BelongsTo` | the model holds the foreign key (to-one) | |
176+
| `HasMany` | the related rows hold the foreign key (to-many) | |
177+
| `BelongsToMany` | many-to-many through a pivot table | `attach` / `sync` / `detach` |
178+
179+
This automatically exposes:
180+
181+
- `GET /api/v1/devices/{id}/ports` list a relation (always a paginated list, even for `BelongsTo`)
182+
- `?include=ports,location` embed relations inline on the parent
183+
- for `BelongsToMany`: `POST .../attach/{rel}`, `POST .../sync/{rel}`, `POST .../detach/{rel}`
184+
185+
### The three identifiers (read this before adding relations)
186+
187+
`HasMany::make($name, TargetRepository::class)` sets two values, and a third is derived. Mixing them up causes `403`/`404`s, so it's worth understanding:
188+
189+
| Identifier | Source | Controls |
190+
|---|---|---|
191+
| **`relation`** | the `make()` first arg | the Eloquent method called to load data (`$model->{relation}()`) |
192+
| **`attribute`** | the `make()` first arg | the `attach`/`sync`/`detach` URL segment |
193+
| **segment / `label`** | auto-set to the **target repository's `uriKey`** | the **`GET` relationship** URL segment |
194+
195+
Restify resolves a nested route `/{parent}/{id}/{segment}` by requiring `{segment}` to (a) be a registered repository `uriKey` **and** (b) match a parent relation's `getAttribute()` (`= label ?? attribute`). The base class `App\Restify\Repository::collectRelated()` keeps these in sync for you it pins every relation's `label` to the target repository's `uriKey`. The practical consequences:
196+
197+
- The `GET` relationship segment is **always the target repository's `uriKey`**, e.g. `DeviceOutageRepository::uriKey() === 'device-outages'``GET /api/v1/devices/{id}/device-outages`.
198+
- The **target model's repository is the single source of truth** for its public name. Rename it once via [`uriKey`](#resource-name--urls-urikey) and every relationship that points to it updates automatically no `->label()` calls scattered across parent repositories.
199+
- You normally just declare the relation with its Eloquent method name; the URL "just works".
200+
201+
### When the Eloquent method name isn't URL-friendly
202+
203+
If the model's relationship method is camelCase (e.g. `devicesOwned()`), pass the kebab-case slug as the first argument (so `attach` URLs stay clean) and point `relation` at the real method with `tap()`:
204+
205+
```php
206+
'devices-owned' => tap(
207+
BelongsToMany::make('devices-owned', DeviceRepository::class),
208+
static fn ($f) => $f->relation = 'devicesOwned', // the actual Eloquent method
209+
),
210+
```
211+
212+
For the example above:
213+
214+
- `GET /api/v1/users/{id}/devices` lists them (segment = `DeviceRepository::uriKey()` = `devices`).
215+
- `POST /api/v1/users/{id}/attach/devices-owned` attaches (segment = `attribute` = `devices-owned`, bridged to `devicesOwned()` by the `RestifyAttachRelationResolver` middleware).
216+
217+
### Missing parent
218+
219+
A relationship request whose parent id doesn't exist (e.g. `/api/v1/devices/999999/ports`) returns `404` via the `EnsureRelatedParentExists` middleware rather than a 500.
220+
221+
## Resource name & URLs (`uriKey`)
222+
223+
Every repository has a **`uriKey`** the slug used both for its own collection path and for any relationship that points at it.
224+
225+
- **Default:** kebab-case plural of the class name minus `Repository` `PortRepository → ports`, `DeviceOutageRepository → device-outages`.
226+
- **Override** on the owning repository:
227+
228+
```php
229+
public static $uriKey = 'device-outages';
230+
```
231+
232+
Because relationship segments are derived from the *target* repository's `uriKey` (see above), this is the **single place** to set a resource's public name changing it moves `/api/v1/<uriKey>` and every `…/{id}/<uriKey>` reference at once.
233+
82234
## Step 2: Create or Update the Policy
83235

84236
Restify checks a `allowRestify()` method on the model's policy to determine if the resource should be accessible at all.
@@ -176,8 +328,10 @@ After registration, these endpoints are automatically available:
176328
| POST | `/api/v1/locations` | Create (if fields are writable and policy allows) |
177329
| PUT/PATCH | `/api/v1/locations/{id}` | Update |
178330
| DELETE | `/api/v1/locations/{id}` | Delete |
331+
| GET | `/api/v1/locations/{id}/<relation>` | List a relation declared in `related()` |
332+
| POST | `/api/v1/locations/{id}/attach/<relation>` | Attach (for `BelongsToMany` relations) |
179333

180-
Plus search, filters, bulk operations, and nested resource support all provided by Restify automatically.
334+
Plus search, filters, bulk operations, and nested resource support all provided by Restify automatically. The OpenAPI document at `/api/v1/openapi.json` updates itself from this configuration no regeneration step.
181335

182336
## Access Control Patterns
183337

@@ -230,8 +384,11 @@ public static function indexQuery(RestifyRequest $request, Builder|Relation $que
230384
| What | Path |
231385
|------|------|
232386
| Repositories | `app/Restify/` |
233-
| Base repository | `app/Restify/Repository.php` |
387+
| Base repository (relationship naming, action gating) | `app/Restify/Repository.php` |
234388
| Service provider | `app/Providers/RestifyServiceProvider.php` |
235-
| Restify config | `config/restify.php` |
389+
| Restify config (middleware stack) | `config/restify.php` |
236390
| Sanctum config | `config/sanctum.php` |
237391
| Policies | `app/Policies/` |
392+
| Relationship-name → method bridge | `app/Http/Middleware/RestifyAttachRelationResolver.php` |
393+
| Missing-parent → 404 guard | `app/Http/Middleware/EnsureRelatedParentExists.php` |
394+
| OpenAPI generator | `app/Services/Api/OpenApi/OpenApiGenerator.php` |

0 commit comments

Comments
 (0)