Skip to content

Commit

Permalink
Merge pull request #15 from DigitalCommons/ts-restify-the-backend
Browse files Browse the repository at this point in the history
Ts restify the backend
  • Loading branch information
wu-lee authored Oct 11, 2024
2 parents 2078959 + 0c79841 commit c4f74d2
Show file tree
Hide file tree
Showing 21 changed files with 519 additions and 382 deletions.
2 changes: 2 additions & 0 deletions apps/back-end/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
"main": "dist/index.js",
"scripts": {
"clean": "rimraf node_modules dist node-dist",
"prebuild": "npm -w ../../libs/common run build",
"build": "vite build --ssr src/index.ts --outDir dist",
"start": "node ./start.js",
"start:attached": "npm run start",
"test": "vitest run",
"predev": "npm -w ../../libs/common run build",
"dev": "vite-node -w start.ts"
},
"dependencies": {
Expand Down
8 changes: 8 additions & 0 deletions apps/back-end/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Tell TS what this is
declare const __BUILD_INFO__: {
name: string;
buildTime: string;
version: number[];
commitDesc: string;
nodeEnv: "development"|"production";
};
11 changes: 8 additions & 3 deletions apps/back-end/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import closeWithGrace from "close-with-grace";
import Fastify from "fastify";
import cors from "@fastify/cors";
import apiPlugin, { options } from "./pluginApi.js";
export { apiPlugin, options as apiOptions }; // For use as a library
import apiPlugin from "./pluginApi.js";
export { apiPlugin }; // For use as a library

// Set the number of milliseconds required for a graceful close to complete
const closeGraceDelay = Number(process.env.FASTIFY_CLOSE_GRACE_DELAY) || 500;
Expand Down Expand Up @@ -56,8 +56,13 @@ export const start = async () => {
origin: corsOrigin,
});

// Mykomap API options
const opts = {
dataRoot: process.env.SERVER_DATA_ROOT ?? "test/data",
};

// Register the API routes
await app.register(apiPlugin, { ...options, prefix: apiPathPrefix });
await app.register(apiPlugin, { mykomap: opts, prefix: apiPathPrefix });

// Start listening
await app.listen({ port: listenPort });
Expand Down
56 changes: 21 additions & 35 deletions apps/back-end/src/pluginApi.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,39 @@
// Fastify plugin autogenerated by fastify-openapi-glue
import openapiGlue, { FastifyOpenapiGlueOptions } from "fastify-openapi-glue";
import * as dotenv from "dotenv";
import { Security } from "./security.js";
import { Service, ServiceOptions } from "./service.js";
import { specification } from "@mykomap/common";
import {
FastifyInstance,
FastifyPluginCallback,
FastifyPluginOptions,
} from "fastify";
import { MykomapRouterConfig, MykomapRouter } from "./routes.js";
import { initServer } from "@ts-rest/fastify";
import { contract } from "@mykomap/common";
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import fp from "fastify-plugin";

dotenv.config();

interface MykomapApiPluginOptions extends FastifyPluginOptions {
// Options for MykomapApiPlugin
serviceOptions?: ServiceOptions;
}

// As per the guide fpr "Creating a TypeScript Fastify Plugin"
// https://fastify.dev/docs/latest/Reference/TypeScript/#creating-a-typescript-fastify-plugin
declare module "fastify" {
interface FastifyRequest {}
interface FastifyReply {}
}

export const pluginApi: FastifyPluginCallback<MykomapApiPluginOptions> = async (
/** Defines our API as a Fastify plugin */
const pluginApi: FastifyPluginCallback<MykomapRouterConfig> = async (
fastify: FastifyInstance,
opts: MykomapApiPluginOptions,
opts: MykomapRouterConfig,
) => {
const pluginOptions: FastifyOpenapiGlueOptions = {
specification,
serviceHandlers: new Service({
options: opts.serviceOptions,
}),
securityHandlers: new Security(),
};
fastify.register(openapiGlue, { ...pluginOptions, ...opts });
};
// Tease apart Mykomap option from all others
const { mykomap, ...fastifyOpts } = opts;

// Get ts-rest's machinery going,
const tsrServer = initServer();

// Feed our implementation to it,
const router = tsrServer.router(contract, MykomapRouter({ mykomap }));

// Plop out a Fastify plugin,
const plugin = tsrServer.plugin(router);

export const options: MykomapApiPluginOptions = {
serviceOptions: {
dataRoot: process.env.SERVER_DATA_ROOT ?? "data",
},
ajv: {
customOptions: {
strict: false,
},
},
// And register it.
fastify.register(plugin, fastifyOpts);
};

/** Export our prepared plugin */
export default fp(pluginApi);
147 changes: 147 additions & 0 deletions apps/back-end/src/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { TsRestResponseError } from "@ts-rest/core";
import { RouterImplementation } from "@ts-rest/fastify";
import { contract } from "@mykomap/common";
import { FastifyPluginOptions, FastifyReply, FastifyRequest } from "fastify";
import fs from "node:fs";
import path from "node:path";

/** Provides the shared configuration options for the Mykomap router implementation. */
export interface MykomapRouterConfig extends FastifyPluginOptions {
/** Options specifically used by Mykomap Api plugin go in here */
mykomap: {
/** Defines the path to the data store */
dataRoot: string;
};
}

//////////////////////////////////////////////////////////////////////
// Helper types

/** The Mykomap API contract type */
type Contract = typeof contract;

//////////////////////////////////////////////////////////////////////
// Helper functions

/** Send a JSON file verbatim as Fastify's reply
*
* This function sends the file as a stream attached to the reply, which avoids
* the need to load the file - which is potentially very large - deserialise it
* from JSON, and then re-serialise it back to JSON.
*
* One consequence of this is that the content cannot be validated. You must
* supply pre-validated files!
*
* @return true on success, false if the path doesn't correspond to a valid file
* (in which case nothing can be sent).If successful, the reply will have
* been modified to have an `application/json` content type, and the file
* content attached as a stream. This reply can be returned from the handler
* instead of a JSON object.
*/
function sendJson(req: FastifyRequest, reply: FastifyReply, file: string) {
req.log.debug(`data file path is '${file}`);

if (!fs.existsSync(file)) return false;

const stream = fs.createReadStream(file, "utf8");
reply.header("Content-Type", "application/json");
reply.send(stream);

return true;
}

//////////////////////////////////////////////////////////////////////

/**
* Constructor for the MykoMap API router implementation.
*
* Accepts the router configuration, and returns an implementation using that configuration.
*
* See the Contract definition for details and documentation of the routes.
*
* Implementation Note: this is not a class, as ts-rest will attempt to recurse over its
* members, and any non-router members seem to make it explode when it does that.
* Therefore, we use a closure to store the configuration options, out of harm's way.
*/
export function MykomapRouter(
opts: MykomapRouterConfig,
): RouterImplementation<Contract> {
// Validation
if (opts?.mykomap?.dataRoot == undefined)
// deliberately loose check
throw new Error("mandatory dataRoot option is not defined");

if (!fs.existsSync(opts.mykomap.dataRoot))
throw new Error(
`the dataRoot plugin option is set but refers to a non-existing path: ` +
`'${opts.mykomap.dataRoot}'.`,
);

// Concatenates the path components into an absolute file path
const filePath = (...components: string[]): string => {
const p = path.join(opts.mykomap.dataRoot ?? "", ...components) + ".json";
return p;
};

// Construct and return the implementation object
return {
async getDataset({ params: { datasetId }, request, reply }) {
// Validate the parameters some more

if (!sendJson(request, reply, filePath("datasets", datasetId)))
throw new TsRestResponseError(contract.getDataset, {
status: 404,
body: { message: `unknown datasetId '${datasetId}'` },
});

return reply;
},

async searchDataset({
params: { datasetId },
query: { filter, text },
request,
reply,
}) {
const filter2 = filter ?? [];

const components = ["datasets", datasetId, "search", ...filter2, "text"];
if (text !== undefined) components.push(encodeURIComponent(text));

if (!sendJson(request, reply, filePath(...components)))
return {
status: 200,
body: [],
};

return reply;
},

async getDatasetItem({
params: { datasetId, datasetItemId },
request,
reply,
}) {
if (
!sendJson(
request,
reply,
filePath("datasets", datasetId, "items", String(datasetItemId)),
)
)
throw new TsRestResponseError(contract.getDatasetItem, {
status: 404,
body: { message: `item retrieve failed` },
});

return reply;
},

async getVersion(req) {
return {
body: __BUILD_INFO__,
status: 200,
};
},
};
}
8 changes: 0 additions & 8 deletions apps/back-end/src/security.ts

This file was deleted.

Loading

0 comments on commit c4f74d2

Please sign in to comment.