Skip to content
This repository has been archived by the owner on Aug 9, 2023. It is now read-only.

184-Create a subscription adapter for Next.js #203

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/subscriptions-nextjs/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @type {import("eslint").Linter.Config}
*/
module.exports = {
extends: "@bonfhir/eslint-config",
overrides: [
{
files: ["**/{.*,*.config}.{cjs,js,mjs,ts}"],
env: {
node: true,
},
},
],
};
1 change: 1 addition & 0 deletions packages/subscriptions-nextjs/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
4 changes: 4 additions & 0 deletions packages/subscriptions-nextjs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Change Log

All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
3 changes: 3 additions & 0 deletions packages/subscriptions-nextjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Bonfhir Subscriptions NextJS Adapter

See https://bonfhir.dev/packages/integrations/subscriptions-nextjs for more information.
99 changes: 99 additions & 0 deletions packages/subscriptions-nextjs/package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* This script is a helper to create the final NPM package.
*/

import { existsSync } from "fs";
import {
copyFile,
readdir,
readFile,
rename,
unlink,
writeFile,
} from "fs/promises";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const execAsync = promisify(exec);

(async () => {
const rootPackageDirectory = dirname(fileURLToPath(import.meta.url));
const distDirectory = join(rootPackageDirectory, "dist");

// Copy package.json while stripping unwanted information
const packageJson = JSON.parse(
await readFile(join(rootPackageDirectory, "package.json"), "utf8")
);
delete packageJson.scripts;
delete packageJson.packageManager;
delete packageJson.devDependencies;
delete packageJson.prettier;

packageJson.exports = {};
for (const packageRootDirName of (
await readdir(distDirectory, { withFileTypes: true })
)
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)) {
packageJson.exports[`./${packageRootDirName}`] = {
types: `./${packageRootDirName}/index.d.ts`,
require: `./${packageRootDirName}/index.cjs`,
import: `./${packageRootDirName}/index.js`,
default: `./${packageRootDirName}/index.js`,
};
}

await writeFile(
join(distDirectory, "package.json"),
JSON.stringify(packageJson),
"utf8"
);

// Copy README.md
await copyFile(
join(rootPackageDirectory, "README.md"),
join(distDirectory, "README.md")
);

// Copy CHANGELOG.md
await copyFile(
join(rootPackageDirectory, "CHANGELOG.md"),
join(distDirectory, "CHANGELOG.md")
);

// Delete TypeScript build info
try {
await unlink(join(distDirectory, "tsconfig.build.tsbuildinfo"));
} catch {
// Ignore the error if the file does not exists.
}

// eslint-disable-next-line no-undef
const npmCommand = process.argv[2] || "pack";
// eslint-disable-next-line no-undef
const npmCommandOptions = process.argv.slice(3).join(" ");

// Run npm in the dist directory
const result = await execAsync(`npm ${npmCommand} ${npmCommandOptions}`, {
cwd: distDirectory,
maxBuffer: 1024 * 1000 * 10,
});

// eslint-disable-next-line no-undef
console.log(result.stdout);
// eslint-disable-next-line no-undef
console.error(result.stderr);

// Copy the package back to root if was produced
const packageFilename = `${packageJson.name
.replace("@", "")
.replace("/", "-")}-${packageJson.version}.tgz`;
if (existsSync(join(distDirectory, packageFilename))) {
await rename(
join(distDirectory, packageFilename),
join(rootPackageDirectory, packageFilename)
);
}
})();
39 changes: 39 additions & 0 deletions packages/subscriptions-nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@bonfhir/subscriptions-nextjs",
"description": "NextJS adapter for bonfhir subscriptions.",
"version": "0.1.0",
"repository": "https://github.com/bonfhir/bonfhir.git",
"license": "APACHE-2.0",
"type": "module",
"scripts": {
"build": "yarn clean && tsup r4b/index.ts --format esm,cjs --out-dir dist/r4b --shims --dts --tsconfig tsconfig.build.json",
"check": "prettier --check ./**/*.ts && eslint ./**/*.ts && tsc --noEmit",
"clean": "rimraf dist/",
"format": "prettier --loglevel warn --write ./**/*.ts && eslint --fix ./**/*.ts",
"package:create": "yarn build && node package.js pack",
"package:publish": "yarn build && node package.js publish"
},
"packageManager": "[email protected]",
"devDependencies": {
"@bonfhir/codegen": "^1.0.0-alpha.5",
"@bonfhir/eslint-config": "^1.1.0-alpha.1",
"@bonfhir/prettier-config": "^1.0.1-alpha.1",
"@bonfhir/typescript-config": "^1.0.1-alpha.2",
"@types/fhir": "^0.0.35",
"@types/node": "^18.15.3",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"eslint": "^8.36.0",
"jest": "^29.5.0",
"prettier": "^2.8.4",
"rimraf": "^4.4.0",
"tsup": "^6.7.0",
"typescript": "^5.0.3"
},
"dependencies": {
"@bonfhir/core": "^1.0.0-alpha.16",
"@bonfhir/subscriptions": "^0.1.0-alpha.7",
"next": "latest"
},
"prettier": "@bonfhir/prettier-config"
}
1 change: 1 addition & 0 deletions packages/subscriptions-nextjs/r4b/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./middleware";
187 changes: 187 additions & 0 deletions packages/subscriptions-nextjs/r4b/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { FhirRestfulClient } from "@bonfhir/core/r4b";
import {
AuditEventConfiguration,
createErrorAuditEvent,
errorToString,
FhirSubscription,
registerSubscriptions,
SubscriptionLogger,
} from "@bonfhir/subscriptions/r4b";

import { Subscription } from "fhir/r4";
import { NextRequest, NextResponse } from "next/server";

export interface FhirSubscriptionsConfig {
/**
* The {@link FhirRestfulClient} to use to register subscriptions.
* If this is a function, it is invoked prior to every handler invocation as well.
*/
fhirClient:
| FhirRestfulClient
| (() => FhirRestfulClient | Promise<FhirRestfulClient>);

/** The subscriptions handlers to register. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subscriptions: FhirSubscription<any>[];

/** The API base URL */
baseUrl: string | URL | null | undefined;

/**
* Indicates how to register the subscriptions:
* - during startup
* - when a specific endpoint is invoked
* - or disable registration
*/
register: "startup" | "endpoint" | "off";

/**
* The registration endpoint to use. Defaults to /fhir/register-subscriptions.
*/
registerEndpoint?: string | null | undefined;

/**
* The name of the security header used. Defaults to "X-Subscription-Auth"
*/
securityHeader?: string | null | undefined;

/** A secret shared between the API and the FHIR subscription use to secure the endpoint. */
webhookSecret: string;

/** The subscription payload, a.k.a. MIME type. Defaults to application/fhir+json */
payload?: Subscription["channel"]["payload"] | null | undefined;

/** Logger to use. Defaults to console. */
logger?: SubscriptionLogger | null | undefined;

/**
* If set, will automatically create AuditEvent using this as a source / reference.
* Alternatively, you can build the AuditEvents yourself.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
auditEvent?: AuditEventConfiguration | null | undefined;
}

/**
* Register subscriptions and routes to webhooks handlers.
*/
export async function fhirSubscriptions(
config: FhirSubscriptionsConfig
): Promise<(request: NextRequest) => Promise<NextResponse>> {
const logger = config.logger ?? console;

const fhirClient =
typeof config.fhirClient === "function"
? await config.fhirClient()
: config.fhirClient;

if (config.register === "startup") {
await registerSubscriptions({
baseUrl: config.baseUrl,
fhirClient,
logger,
subscriptions: config.subscriptions,
webhookSecret: config.webhookSecret,
securityHeader: config.securityHeader,
auditEvent: config.auditEvent,
payload: config.payload,
});
}

const securityHeader = config.securityHeader || "X-Subscription-Auth";

function verifySecurityHeader(request: NextRequest): boolean {
return (
request.headers.has(securityHeader) &&
request.headers.get(securityHeader) === config.webhookSecret
);
}

async function fhirMiddleware(request: NextRequest): Promise<NextResponse> {
if (config.register === "endpoint") {
if (!verifySecurityHeader(request)) {
logger?.warn(`Received unauthorized request for ${request.url}.`);
return new NextResponse(null, {
status: 401,
});
}
if (
request.method === "POST" &&
request.nextUrl.pathname ===
(config.registerEndpoint || "/fhir/register-subscriptions")
) {
try {
await registerSubscriptions({
baseUrl: config.baseUrl,
fhirClient,
logger,
subscriptions: config.subscriptions,
webhookSecret: config.webhookSecret,
auditEvent: config.auditEvent,
payload: config.payload,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return new NextResponse(`Error: ${error}`, { status: 500 });
}
}
}

const subscription = config.subscriptions.find(
(sub) => `${sub.endpoint}` === request.nextUrl.pathname
);

if (subscription) {
if (!verifySecurityHeader(request)) {
logger?.warn(`Received unauthorized request for ${request.url}.`);
return new NextResponse(null, {
status: 401,
});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resource = await request.json();
try {
const result = await subscription.handler({
fhirClient:
typeof config.fhirClient === "function"
? await config.fhirClient()
: config.fhirClient,
resource,
logger,
});

if (result == null) {
return new NextResponse(null, {
status: 204,
});
} else {
return new NextResponse(JSON.stringify(result), {
status: 200,
});
}
} catch (error) {
logger.error(error);
if (config.auditEvent) {
try {
await createErrorAuditEvent({
auditEvent: config.auditEvent,
error,
fhirClient,
relatedResource: resource,
});
} catch (auditEventError) {
logger.error(auditEventError);
}
}
return new NextResponse(errorToString(error), {
status: 500,
});
}
}

return NextResponse.next();
}

return fhirMiddleware;
}
8 changes: 8 additions & 0 deletions packages/subscriptions-nextjs/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "**/__fixtures__"],
"compilerOptions": {
"incremental": false,
"outDir": "dist"
}
}
7 changes: 7 additions & 0 deletions packages/subscriptions-nextjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@bonfhir/typescript-config/tsconfig.json",
"include": ["**/*.ts"],
"compilerOptions": {
"rootDir": "."
}
}
14 changes: 14 additions & 0 deletions samples/sample-api-nextjs/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* @type {import("eslint").Linter.Config}
*/
module.exports = {
extends: "@bonfhir/eslint-config",
overrides: [
{
files: ["**/{.*,*.config}.{cjs,js,mjs,ts,tsx}"],
env: {
node: true,
},
},
],
};
Loading