Directus
@ginjou/with-directus provides two adapters for the Directus SDK.
It gives you a Ginjou fetcher and a Ginjou auth provider. Most apps pass the same Directus client to both.
This package does not change how higher-level Ginjou hooks are used. It only connects them to Directus.
Installation
Install @directus/sdk together with the adapter.
pnpm add @ginjou/with-directus @directus/sdk
yarn add @ginjou/with-directus @directus/sdk
npm install @ginjou/with-directus @directus/sdk
bun add @ginjou/with-directus @directus/sdk
@directus/sdk | @ginjou/with-directus |
|---|---|
>=20.0.0 <26.0.0 | latest |
^15.0.0 | 0.1.0-beta.16 |
20.0.0 changed client.login() from positional arguments to a payload object, so the latest release requires 20 or newer. SDK 16–19 were never a declared peer range, but they kept the positional login(), so 0.1.0-beta.16 works there aside from the peer-dependency warning.Fetcher
Use createFetcher() with a Directus client that has REST support.
| Prop | Required | Meaning |
|---|---|---|
client | Yes | A Directus client with REST capabilities. |
Register it through defineFetchersContext().
import { authentication, createDirectus, rest } from '@directus/sdk'
import { defineFetchersContext } from '@ginjou/vue'
import { createFetcher } from '@ginjou/with-directus'
const directus = createDirectus('https://your-directus.example.com')
.with(rest({ credentials: 'include' }))
.with(authentication('session', { autoRefresh: false, credentials: 'include' }))
defineFetchersContext({
default: createFetcher({ client: directus }),
})
The adapter implements getList, getOne, getMany, createOne, createMany, updateOne, updateMany, deleteOne, deleteMany, and custom.
getMany reads by id with an _in filter and sets limit to the number of ids, ignoring any meta.query.limit, which could only truncate the result. The filter goes into _and, so an id filter you pass through meta.query.filter is kept as well.
When Directus answers with no body
Directus writes first and then reads the row back to build its response. A policy that grants the write but not the read makes that read-back forbidden, and Directus answers success with no body rather than undoing a write that already succeeded. Deletes always answer with no body, since there is nothing left to read.
Rather than hand you an empty result, the adapter rebuilds the record from what it just sent.
| Method | Returned when the body is empty |
|---|---|
createOne | The params you passed. |
createMany | The params array you passed. |
updateOne | { ...params, id }. |
updateMany | One { ...params, id } per id. |
deleteOne | { id }. |
deleteMany | One { id } per id. |
A real body always wins; this only fills a gap. The one thing that cannot be recovered is a server-generated key on create, so a createOne whose response was withheld comes back without an id unless you supplied one yourself.
Collections and System Collections
Normal resources use the generic SDK item helpers such as readItems() and updateItem().
System resources are different. If the resource starts with directus_ or directus/, the adapter switches to the matching dedicated SDK helper.
| Resource name | Directus SDK helper pattern |
|---|---|
posts | readItems, readItem, createItem, createItems, updateItem, updateItems, deleteItem, deleteItems |
directus_users | readUsers, readUser, createUser, createUsers, updateUser, updateUsers, deleteUser, deleteUsers |
That lets one resource name work for both normal collections and Directus system collections.
Pagination
List pagination is translated into Directus page and limit.
| Ginjou input | Directus query |
|---|---|
pagination.current | page |
pagination.perPage | limit |
For totals, the adapter runs a second aggregate() request.
By default it asks Directus for countDistinct: 'id'.
Filters
The adapter converts Ginjou filters into Directus filter objects.
| Ginjou operator | Directus operator |
|---|---|
eq | _eq |
ne | _neq |
lt | _lt |
gt | _gt |
lte | _lte |
gte | _gte |
in | _in |
nin | _nin |
contains | _icontains |
containss | _contains |
ncontains | _nicontains |
ncontainss | _ncontains |
null | _null |
nnull | _nnull |
between | _between |
nbetween | _nbetween |
startswith | _istarts_with |
startswiths | _starts_with |
nstartswith | _nistarts_with |
nstartswiths | _nstarts_with |
endswith | _iends_with |
endswiths | _ends_with |
nendswith | _niends_with |
nendswiths | _nends_with |
or | _or |
and | _and |
Ginjou operators without an s suffix are case-insensitive and map to the Directus _i* variants. The s suffix means case-sensitive and maps to the plain Directus operators.
An unknown operator throws instead of being dropped.
There are a few extra rules worth knowing.
| Case | Behavior |
|---|---|
Filter on search | Routed into Directus search. |
getList() default filter | None. The adapter adds nothing of its own. |
Empty values in meta.query.filter | Sent as written. |
Empty _and / _or group in filters, or a top-level empty _and in meta.query.filter | Dropped. |
The adapter runs no scrubbing pass over your query, so _eq: null, _eq: '' and _in: [] reach Directus unchanged. These are real conditions, and _eq: null in particular is how you ask for rows that were never soft-deleted. Dropping it would silently widen the query to the whole collection. Keys you leave as undefined are omitted by the Directus SDK itself.
A top-level empty group in filters is the one thing dropped, and dropping it changes nothing. Directus turns _or: [] into an empty SQL group that its query builder omits, so the query it runs is identical whether the group is sent or not. An empty top-level _and in meta.query.filter goes the same way, since the resolved filters are merged into that same array. An empty group nested deeper is forwarded rather than hunted down.
_in: [] is the opposite case and is always sent: it compiles to 1 = 0 and matches no rows, so dropping it would silently turn "match nothing" into "match everything".
Anything you want applied to every list query goes through meta.query.filter, and is merged with the filters resolved from filters. A status gate, for example:
const { data } = useList({
resource: 'posts',
meta: {
query: {
filter: { status: { _neq: 'archived' } },
},
},
})
Sorters
Sorters are converted into Directus sort strings.
| Ginjou sorter | Directus sort entry |
|---|---|
{ field: 'title', order: 'asc' } | title |
{ field: 'createdAt', order: 'desc' } | -createdAt |
Multiple sorters are joined with commas.
For example, title asc plus createdAt desc becomes title,-createdAt.
Custom
custom() accepts filters and sorters, converts them with the same rules as above, and sends them as the filter and sort query parameters. Your own query is applied last and wins any key it sets.
custom({
url: '/items/posts',
method: 'get',
filters: [{ field: 'status', operator: 'eq', value: 'published' }],
sorters: [{ field: 'date', order: 'desc' }],
})
// GET /items/posts?filter={"_and":[{"status":{"_eq":"published"}}]}&sort=-date
Meta
The verified meta entry points are meta.query and meta.aggregate.
| Meta field | Used by | What it does |
|---|---|---|
meta.query | getList, getOne, createOne, updateOne | Pass through Directus query options such as fields, filter, sort, page, and limit. |
meta.aggregate | getList | Override the aggregate descriptor used for total count. |
groupBy exists in the current type, but it is not used by the implementation.
Do not rely on it as a working feature yet.
import { useGetList } from '@ginjou/vue'
useGetList({
resource: 'posts',
meta: {
query: {
fields: ['id', 'title', 'user_created.first_name'],
filter: {
status: {
_eq: 'published',
},
},
},
},
})
import { useGetList } from '@ginjou/vue'
useGetList({
resource: 'posts',
meta: {
aggregate: {
count: '*',
},
},
})
Auth
Use createAuth() with a Directus client that has authentication and REST support.
Most apps reuse the same Directus client they already passed to createFetcher().
Register it through defineAuthContext().
import { authentication, createDirectus, rest } from '@directus/sdk'
import { defineAuthContext } from '@ginjou/vue'
import { createAuth } from '@ginjou/with-directus'
const directus = createDirectus('https://your-directus.example.com')
.with(rest({ credentials: 'include' }))
.with(authentication('session', { autoRefresh: false, credentials: 'include' }))
defineAuthContext(createAuth({ client: directus }))
Login
Two login types are supported, and they work in completely different ways.
| Login type | Input | What happens |
|---|---|---|
password | email, password, optional options | client.login({ email, password }, options) |
sso | provider, optional redirect | Full page navigation to /auth/login/{provider}?redirect=… |
Password login:
import { useLogin } from '@ginjou/vue'
const { mutateAsync: login } = useLogin()
await login({
type: 'password',
params: {
email: 'user@example.com',
password: 'password123',
},
})
SSO login navigates the browser away from your app, so nothing after the call runs:
import { useLogin } from '@ginjou/vue'
const { mutateAsync: login } = useLogin()
await login({
type: 'sso',
params: {
provider: 'google',
// Defaults to the current page.
redirect: 'https://app.example.com/callback',
},
})
client.login(payload, { provider }) posts credentials to the same endpoint, which only works for the local and ldap drivers — OAuth2, OpenID and SAML need the browser itself to make the trip so the provider can redirect it back.Directus must be configured to allow the return trip, and to hand back a session cookie:
AUTH_<PROVIDER>_MODE="session"
AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST="https://app.example.com/callback"
Create the client in session mode so that cookie is sent with later requests:
import { authentication, createDirectus, rest } from '@directus/sdk'
const directus = createDirectus('https://directus.example.com')
.with(authentication('session', { autoRefresh: false, credentials: 'include' }))
.with(rest({ credentials: 'include' }))
Once the browser lands back on your app, check() picks the session up on its own — there is no callback code to write. See Check.
If you would rather render the endpoint as a plain link than call login(), getSSOLoginUrl(client, provider, redirect?) returns the same URL without navigating.
import { getSSOLoginUrl } from '@ginjou/with-directus'
const href = getSSOLoginUrl(directus, 'google')
Logout
logout() maps to client.logout().
It clears the Directus session through the SDK.
Identity
getIdentity() maps to client.request(readMe()).
That returns the current Directus user profile.
Check Authentication
check() reads client.getToken(), and falls back to client.refresh() once when there is no token.
Set autoRefresh: false: this adapter synchronizes refresh with logout, whereas the SDK's automatic refresh can otherwise restore credentials after logout.
Set credentials: 'include' on both rest() and authentication(): session mode keeps the session in a cookie, and cross-origin requests only send it when the fetch opts in.
| Token state | Result |
|---|---|
| Token exists | { authenticated: true } |
| No token, refresh succeeds | { authenticated: true } |
| No token, refresh fails | { authenticated: false } |
The fallback matters because an empty token store is not proof of being logged out. client.getToken() never issues a request of its own, and the SDK stores tokens in memory by default, so the store is empty in two ordinary situations:
- The browser has just come back from an SSO redirect, carrying only the cookie Directus set.
- The page was reloaded, and no persistent
storagewas configured onauthentication().
Without the fallback both look like a logged-out user. With it, one POST /auth/refresh recovers the session and no callback route is needed after SSO.
check() for a genuinely anonymous visitor. Concurrent checks share a single in-flight request.Check Error
checkError() inspects Directus client errors.
It returns { logout: true } only for verified auth-related Directus error codes.
| Directus error code | Behavior |
|---|---|
TOKEN_EXPIRED | Return { logout: true, error } |
INVALID_CREDENTIALS | Return { logout: true, error } |
INVALID_IP | Return { logout: true, error } |
INVALID_OTP | Return { logout: true, error } |
Other errors return an empty object, so non-auth failures do not force logout automatically.