Goal for this step 🏁: Add a REST API to the backend for admin 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 an admin client in the backend for a request. In backend/server.ts
:
export function getAdminClientForRequest(server: Server, req: Request) {
const session = createSessionForRequest(server, req);
return server.createAdminClient<AppAdminClient>(() => session);
}
This is how we support all admin operations in backend/main.ts
. The main utility is executeAdminClientOperationFromJson()
which takes the JSON representation of an operation and executes it on an admin client. We use decodeURLSearchParamsParam()
to parse the search parameter for the GET
requests. In backend/main.ts
:
import {
AdminClientModifyingOperations,
decodeURLSearchParamsParam,
executeAdminClientOperationFromJson,
notOk,
type AdminClientJsonOperationArgs,
type ErrorType
type Result,
} from '@dossierhq/core';
import { Response } from 'express';
import { getAdminClientForRequest } 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 adminClient = getAdminClientForRequest(server, req);
const { operationName } = req.params;
const operationArgs = decodeURLSearchParamsParam<AdminClientJsonOperationArgs>(
req.query as Record<string, string>,
'args'
);
const operationModifies = AdminClientModifyingOperations.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 executeAdminClientOperationFromJson(adminClient, operationName, operationArgs)
);
}
})
);
app.put(
'/api/admin/:operationName',
asyncHandler(async (req, res) => {
const adminClient = getAdminClientForRequest(server, req);
const { operationName } = req.params;
const operationArgs = req.body as AdminClientJsonOperationArgs;
const operationModifies = AdminClientModifyingOperations.has(operationName);
if (!operationModifies) {
sendResult(res, notOk.BadRequest('Operation does not modify data, but PUT was used'));
} else {
sendResult(
res,
await executeAdminClientOperationFromJson(adminClient, operationName, operationArgs)
);
}
})
);
In the frontend we want to use an admin client to talk to the backend. But since we don't have direct access to the Server
instance, we use createBaseAdminClient()
to create the admin client (in the backend we used server.createAdminClient()
). 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 {
convertJsonAdminClientResult,
createBaseAdminClient,
createConsoleLogger,
encodeObjectToURLSearchParams,
notOk,
ok,
type AdminClientOperation,
type ClientContext,
} from '@dossierhq/core';
import { useMemo } from 'react';
import { AppAdminClient } from './SchemaTypes.js';
const logger = createConsoleLogger(console);
export function useAdminClient(): AppAdminClient | null {
return useMemo(
() =>
createBaseAdminClient<ClientContext, AppAdminClient>({
context: { logger },
pipeline: [createAdminBackendMiddleware()],
}),
[]
);
}
function createAdminBackendMiddleware() {
return async (context: ClientContext, operation: AdminClientOperation): 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(convertJsonAdminClientResult(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 admin 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 { useAdminClient } from './ClientUtils.js';
import type { AppAdminEntity } from './SchemaTypes.js';
export function IndexRoute() {
const adminClient = useAdminClient();
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 (!adminClient) return;
const result = await adminClient.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}`);
}
}, [adminClient, newMessage]);
useEffect(() => {
if (adminClient) {
adminClient.getEntitiesSample({}, { seed: adminSampleSeed, count: 5 }).then((result) => {
if (result.isOk()) setAdminSample(result.value);
});
}
}, [adminClient, 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>
);
}