Tutorial – Admin REST API

Goal for this step 🏁: Add a REST API to the backend for Dossier client and use it in the frontend to fetch and create new Message entities.

In order to talk to the backend from the frontend we'll set up a REST API, but instead of manually creating an endpoint for each operation, we'll use some of the provided utilities to create a GET endpoint at /api/admin/:operationName (where :operationName is getEntity, getEntities etc) for read-only operations and a PUT endpoint at the same url for write operations.

For the GET requests we provide the arguments of the operation in the args search parameter (e.g. /api/admin/getEntity?args=[{"id":"e6e151f5-b93e-4fa7-9d78-b097b1449cc7"}]). For PUT requests we provide them in the body of the request as JSON. You're free to design the API as you wish for your application, since you control both the frontend and the backend.

First, we need to create a Dossier client in the backend for a request. In backend/server.ts:

export function getDossierClientForRequest(server: Server, req: Request) {
const session = createSessionForRequest(server, req);
return server.createDossierClient<AppDossierClient>(() => session);
}

This is how we support all admin operations in backend/main.ts. The main utility is executeJsonDossierClientOperation() which takes the JSON representation of an operation and executes it on a Dossier client. We use decodeURLSearchParamsParam() to parse the search parameter for the GET requests. In backend/main.ts:

import {
DossierClientModifyingOperations,
decodeURLSearchParamsParam,
executeJsonDossierClientOperation,
notOk,
type JsonDossierClientOperationArgs,
type ErrorType
type Result,
} from '@dossierhq/core';
import { Response } from 'express';
import { getDossierClientForRequest } from './server.js';

function sendResult(res: Response, result: Result<unknown, ErrorType>) {
if (result.isError()) {
res.status(result.httpStatus).send(result.message);
} else {
res.json(result.value);
}
}

app.get(
'/api/admin/:operationName',
asyncHandler(async (req, res) => {
const client = getDossierClientForRequest(server, req);
const { operationName } = req.params;
const operationArgs = decodeURLSearchParamsParam<JsonDossierClientOperationArgs>(
req.query as Record<string, string>,
'args'
);
const operationModifies = DossierClientModifyingOperations.has(operationName);
if (operationModifies) {
sendResult(res, notOk.BadRequest('Operation modifies data, but GET was used'));
} else if (!operationArgs) {
sendResult(res, notOk.BadRequest('Missing args'));
} else {
sendResult(
res,
await executeJsonDossierClientOperation(client, operationName, operationArgs)
);
}
})
);

app.put(
'/api/admin/:operationName',
asyncHandler(async (req, res) => {
const client = getDossierClientForRequest(server, req);
const { operationName } = req.params;
const operationArgs = req.body as JsonDossierClientOperationArgs;
const operationModifies = DossierClientModifyingOperations.has(operationName);
if (!operationModifies) {
sendResult(res, notOk.BadRequest('Operation does not modify data, but PUT was used'));
} else {
sendResult(
res,
await executeJsonDossierClientOperation(client, operationName, operationArgs)
);
}
})
);

In the frontend we want to use a Dossier client to talk to the backend. But since we don't have direct access to the Server instance, we use createBaseDossierClient() to create the Dossier client (in the backend we used server.createDossierClient()). By using a middleware we intercept the operations, send them to the backend, and convert the JSON from the response back to JavaScript objects. In src/ClientUtils.ts we set it all up.

import {
convertJsonDossierClientResult,
createBaseDossierClient,
createConsoleLogger,
encodeObjectToURLSearchParams,
notOk,
ok,
type DossierClientOperation,
type ClientContext,
} from '@dossierhq/core';
import { useMemo } from 'react';
import { AppDossierClient } from './SchemaTypes.js';

const logger = createConsoleLogger(console);

export function useDossierClient(): AppDossierClient | null {
return useMemo(
() =>
createBaseDossierClient<ClientContext, AppDossierClient>({
context: { logger },
pipeline: [createAdminBackendMiddleware()],
}),
[]
);
}

function createAdminBackendMiddleware() {
return async (context: ClientContext, operation: DossierClientOperation): Promise<void> => {
let response: Response;
if (operation.modifies) {
response = await fetch(`/api/admin/${operation.name}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(operation.args),
});
} else {
response = await fetch(
`/api/admin/${operation.name}?${encodeObjectToURLSearchParams(
{ args: operation.args },
{ keepEmptyObjects: true }
)}`
);
}

const result = await getBodyAsJsonResult(response);
operation.resolve(convertJsonDossierClientResult(operation.name, result));
};
}

async function getBodyAsJsonResult(response: Response) {
if (response.ok) {
try {
return ok(await response.json());
} catch (error) {
return notOk.Generic('Failed parsing response');
}
} else {
let text = 'Failed fetching response';
try {
text = await response.text();
} catch (error) {
// ignore
}
return notOk.fromHttpStatus(response.status, text);
}
}

Finally we can use the Dossier client in the frontend to both create and fetch entities. In src/IndexRoute.tsx:

import type { EntitySamplingPayload } from '@dossierhq/core';
import { useCallback, useEffect, useState } from 'react';
import { useDossierClient } from './ClientUtils.js';
import type { AppAdminEntity } from './SchemaTypes.js';

export function IndexRoute() {
const client = useDossierClient();
const [message, setMessage] = useState<string | null>(null);
const [newMessage, setNewMessage] = useState('');
const [adminSampleSeed, setAdminSampleSeed] = useState(Math.random);
const [adminSample, setAdminSample] = useState<EntitySamplingPayload<AppAdminEntity> | null>(
null
);

useEffect(() => {
fetch('/api/message')
.then((res) =>
res.ok
? res.json()
: { message: `Failed to fetch message: ${res.status} ${res.statusText}` }
)
.then((data) => setMessage(data.message));
}, []);

const handleSendMessageClick = useCallback(async () => {
if (!client) return;
const result = await client.createEntity(
{
info: { type: 'Message', authKey: 'none', name: newMessage },
fields: { message: newMessage },
},
{ publish: true }
);
if (result.isOk()) {
setNewMessage('');
} else {
alert(`Failed to create message: ${result.error}: ${result.message}`);
}
}, [client, newMessage]);

useEffect(() => {
if (client) {
client.getEntitiesSample({}, { seed: adminSampleSeed, count: 5 }).then((result) => {
if (result.isOk()) setAdminSample(result.value);
});
}
}, [client, adminSampleSeed]);

return (
<div style={{ maxWidth: '30rem', marginRight: 'auto', marginLeft: 'auto' }}>
<h1 style={{ fontSize: '2em' }}>Dossier</h1>
{message && <div>Got: {message}</div>}

<h2 style={{ fontSize: '1.75em' }}>Create message entity</h2>
<div>
<input onChange={(e) => setNewMessage(e.target.value)} value={newMessage} />
<br />
<button disabled={!newMessage} onClick={handleSendMessageClick}>
Create
</button>
</div>

<h2 style={{ fontSize: '1.75em' }}>Sample admin entities</h2>
{adminSample && (
<ul>
{adminSample.items.map((it) => (
<li key={it.id}>
{it.info.type}: {it.info.name}
</li>
))}
</ul>
)}
<button onClick={() => setAdminSampleSeed(Math.random())}>Refresh</button>
</div>
);
}
The frontend with the form to create a message and listing random messages