Skip to content
This repository has been archived by the owner on Jun 13, 2022. It is now read-only.

Migrate CDS Hooks + Rest modules to fastify-plugin architecture #86

Merged
merged 1 commit into from
Sep 22, 2021
Merged
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: 7 additions & 7 deletions src/cds-hooks/card.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { randomUUID } from "crypto";
import Suggestion, { AcceptedSuggestion } from "./suggestion.js";
import CDSSuggestion, { AcceptedSuggestion } from "./suggestion.js";

/**
* A **Card** contains decision support from a CDS Service.
* A **CDSCard** contains decision support from a CDS Service.
*
* Generally speaking, cards are intended for display to an end user. The data
* format of a card defines a very minimal set of required attributes with
* several more optional attributes to suit a variety of use cases. For
* instance, narrative informational decision support, actionable suggestions to
* modify data, and links to SMART apps.
*
* One or many {@link Suggestion} can be created with each card.
* One or many {@link CDSSuggestion} can be created with each card.
*
* @version https://cds-hooks.hl7.org/ballots/2020Sep/
*
* @example Here's an example of creating a Card without any suggestions:
* ```typescript
* new Card({
* new CDSCard({
* summary: "High risk for opioid overdose - taper now",
* detail: "Total morphine milligram equivalent (MME) is 125mg. Taper to less than 50.",
* source: {
Expand All @@ -31,7 +31,7 @@ import Suggestion, { AcceptedSuggestion } from "./suggestion.js";
* })
* ```
*/
export default class Card {
export default class CDSCard {
/**
* Unique identifier of the card. MAY be used for auditing and logging cards
* and SHALL be included in any subsequent calls to the CDS service's feedback
Expand Down Expand Up @@ -69,7 +69,7 @@ export default class Card {
* prescribed, for the `medication-prescribe` activity). If suggestions are
* present, `selectionBehavior` MUST also be provided.
*/
suggestions?: Suggestion[];
suggestions?: CDSSuggestion[];
/**
* Describes the intended selection behavior of the suggestions in the card.
* Allowed values are: at-most-one, indicating that the user may choose none
Expand All @@ -93,7 +93,7 @@ export default class Card {
*/
links?: Link[];

constructor(options: Partial<Card> & { source: Source; summary: string; indicator: 'info' | 'warning' | 'critical' } ) {
constructor(options: Partial<CDSCard> & { source: Source; summary: string; indicator: 'info' | 'warning' | 'critical' } ) {
this.uuid = options.uuid || randomUUID();
this.detail = options.detail;
this.suggestions = options.suggestions;
Expand Down
2 changes: 1 addition & 1 deletion src/cds-hooks/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from "crypto";
import { http as app } from "../../test/fixtures/server"
import { http as app } from "../../test/fixtures/cdsServer"

/**
* Setting a mock UUID for all tests
Expand Down
10 changes: 5 additions & 5 deletions src/cds-hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* @module cds-hooks
*/

export { default as mount } from "./routes.js";
export { default as Service, HookRequest, HookResponse } from "./service.js";
export { default as Card } from "./card.js";
export { default as Suggestion } from "./suggestion.js";
export { NoDecisionResponse } from "./util.js";
export { default as default } from "./routes.js";
export { default as CDSService, CDSHookRequest, CDSHookResponse } from "./service.js";
export { default as CDSCard } from "./card.js";
export { default as CDSSuggestion } from "./suggestion.js";
export { CDSNoDecisionResponse } from "./util.js";
38 changes: 22 additions & 16 deletions src/cds-hooks/routes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from "fastify";
import fastifyPlugin from "fastify-plugin";
import Service from "./service.js";
import Config from "../config";
import { Feedback } from "./card.js";
import { HookRequest } from "./service.js";
import { validateHookRequest, getService, NoDecisionResponse } from "./util.js";

export default function mount(config: Config, http: FastifyInstance): void {
routes(http, config.cdsHooks);
}
import { CDSHookRequest } from "./service.js";
import { validateHookRequest, getService, CDSNoDecisionResponse } from "./util.js";

/**
* @deprecated This should be invoked some other way
Expand All @@ -19,8 +15,14 @@ function addCorsHeaders(reply: FastifyReply): void {
reply.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
}

interface CDSServicePluginOptions {
services: Service[];
cors?: boolean;
}


/**
* Responsible for validating the HookRequest, finding the service, and calling it
* Responsible for validating the CDSHookRequest, finding the service, and calling it
* - or appropriately responding with and error
*
* @remarks This function is expected to increase in complexity significantly if
Expand All @@ -31,7 +33,7 @@ function addCorsHeaders(reply: FastifyReply): void {
*
* @param options -
*/
function invoke(options: Config["cdsHooks"]) {
function invoke(options: CDSServicePluginOptions) {
return async (request: FastifyRequest<{ Params: { id: string }}>, reply: FastifyReply) => {

if (options?.cors) addCorsHeaders(reply);
Expand All @@ -46,10 +48,10 @@ function invoke(options: Config["cdsHooks"]) {
reply.log.info("SchemaValidationError")
reply.code(400).send(request.validationError);
} else {
const hookRequest = request.body as HookRequest<Record<string, string>>;
const hookRequest = request.body as CDSHookRequest<Record<string, string>>;
const validationError = validateHookRequest(hookRequest, service);

// 3. Is there a dynamic validation error on this HookRequest?
// 3. Is there a dynamic validation error on this CDSHookRequest?
if (validationError) {
reply.log.info("HookRequestValidationError")
reply.code(400).send(validationError)
Expand All @@ -59,7 +61,7 @@ function invoke(options: Config["cdsHooks"]) {
const response = await service.handler(hookRequest);
reply.send(response);
} catch (error) {
if (error instanceof NoDecisionResponse) {
if (error instanceof CDSNoDecisionResponse) {
reply.log.info("NoDecisionResponse")
reply.send({
cards: []
Expand All @@ -78,11 +80,11 @@ function invoke(options: Config["cdsHooks"]) {
* Responsible for accepting feedback requests as defined by the protocol. Logs the request
* but doesn't otherwise process or validate it.
*
* @todo validate that the feedback has referrential integrity to some original HookRequest
* @todo validate that the feedback has referrential integrity to some original CDSHookRequest
*
* @param options -
*/
function feedback(options: Config["cdsHooks"]) {
function feedback(options: CDSServicePluginOptions) {
return (request: FastifyRequest<{ Params: { id: string }}>, reply: FastifyReply) => {
if (options?.cors) addCorsHeaders(reply);

Expand Down Expand Up @@ -149,7 +151,7 @@ const feedbackSchema = {
* @param http - A fastify instance
* @param options - The services and
*/
function routes(http: FastifyInstance, options: Config["cdsHooks"]): void {
const oauthPlugin: FastifyPluginCallback<CDSServicePluginOptions> = function (http, options: CDSServicePluginOptions, next) {
http.route({
method: 'GET',
url: '/cds-services',
Expand Down Expand Up @@ -197,8 +199,12 @@ function routes(http: FastifyInstance, options: Config["cdsHooks"]): void {
}
})
}

next();
}

export default fastifyPlugin(oauthPlugin, { name: "cds-services"})

/**
* The response to the discovery endpoint SHALL be an object containing a list
* of CDS Services.
Expand Down
38 changes: 19 additions & 19 deletions src/cds-hooks/service.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
import { randomUUID } from "crypto";
import { Hooks, SystemAction, FhirAuthorization } from "./util";
import Card from "./card";
import CDSCard from "./card";

/**
* ServiceHandler is a function signature for the function invoked on the
* service to resolve a HookRequest
* service to resolve a CDSHookRequest
*
* @todo Is it possible to structure the generic here on something other than
* `any`?
*/
type ServiceHandler = {
(request: HookRequest<any>): Promise<HookResponse> | HookResponse;
(request: CDSHookRequest<any>): Promise<CDSHookResponse> | CDSHookResponse;
};

/**
* A **Service** that provides patient-specific recommendations and guidance
* A **CDSService** that provides patient-specific recommendations and guidance
* through RESTful APIs as described by the CDS Hooks spec.
*
* The primary APIs are Discovery, which allows a CDS Developer to publish the
* types of CDS Services it provides, and the Service endpoint that CDS Clients
* use to request and invoke decision support services.
*
* **Service** automatically creates an HTTP API conformant with the CDS Hooks
* spec for you with just a few lines of code. Internally, **Service** uses
* {@link http} and to deliver its HTTP API. **Service** can be used in
* **CDSService** automatically creates an HTTP API conformant with the CDS Hooks
* spec for you with just a few lines of code. Internally, **CDSService** uses
* {@link http} and to deliver its HTTP API. **CDSService** can be used in
* isolation or in combination with other modules composed on top of
* {@link http} like {@link rest} and {@link smart}.
*
* When invoked in response to a HookRequest event by a CDS Client, Service will
* When invoked in response to a CDSHookRequest event by a CDS Client, Service will
* first validate the request and then call the invocation function as defined
* at construction. This function, {@link handler}, receives the prefetch
* response in the HookRequest as defined by the Service up front. You can
* response in the CDSHookRequest as defined by the Service up front. You can
* perform any necessary work here necessary to satisfy the request. Calling out
* to a tensorflow model, or passing parameters to an external API for example.
*
Expand All @@ -41,7 +41,7 @@ type ServiceHandler = {
*
* @example Here's an example:
* ```typescript
* new Service(
* new CDSService(
* {
* title: "patient-view Hook Service Example",
* hook: "patient-view",
Expand All @@ -58,7 +58,7 @@ type ServiceHandler = {
* )
* ```
*/
export default class Service implements Service {
export default class CDSService implements CDSService {
/**
* The id portion of the URL to this service which is available at
* `{baseUrl}/cds-services/{id}`
Expand All @@ -84,7 +84,7 @@ export default class Service implements Service {
*/
public prefetch?: PrefetchTemplate;
/**
* A function to execute on HookRequest invocation events
* A function to execute on CDSHookRequest invocation events
*/
public handler: ServiceHandler;
/**
Expand All @@ -98,14 +98,14 @@ export default class Service implements Service {
public extension?: Extension;

/**
* Pass any options along with a function that will execute on HookRequest
* Pass any options along with a function that will execute on CDSHookRequest
* invocation events
*
* @param options -
* @param fn -
*/
constructor(
options: Partial<Service> & { hook: Hooks; description: string },
options: Partial<CDSService> & { hook: Hooks; description: string },
handler: ServiceHandler
) {
this.hook = options.hook;
Expand All @@ -118,15 +118,15 @@ export default class Service implements Service {
}
}

export interface HookResponse {
export interface CDSHookResponse {
/**
* An array of Cards. Cards can provide a combination of information (for
* reading), suggested actions (to be applied if a user selects them), and
* links (to launch an app if the user selects them). The CDS Client decides
* how to display cards, but this specification recommends displaying
* suggestions using buttons, and links using underlined text.
*/
cards?: Card[];
cards?: CDSCard[];

/**
* An array of actions that the CDS Service proposes to auto-apply. Each
Expand Down Expand Up @@ -156,7 +156,7 @@ interface Extension {
[key: string]: any;
}

interface HookRequestWithFhir<T> extends HookRequestBasic<T> {
interface CDSHookRequestWithFhir<T> extends CDSHookRequestBasic<T> {
/**
* The base URL of the CDS Client's FHIR server. If fhirAuthorization is
* provided, this field is REQUIRED. The scheme should be https
Expand All @@ -171,7 +171,7 @@ interface HookRequestWithFhir<T> extends HookRequestBasic<T> {
fhirAuthorization: FhirAuthorization;
}

interface HookRequestBasic<T> {
interface CDSHookRequestBasic<T> {
/**
* The hook that triggered this CDS Service call. See:
* https://cds-hooks.org/specification/current/#hooks
Expand All @@ -196,4 +196,4 @@ interface HookRequestBasic<T> {
prefetch: T;
}

export type HookRequest<T> = HookRequestBasic<T> | HookRequestWithFhir<T>;
export type CDSHookRequest<T> = CDSHookRequestBasic<T> | CDSHookRequestWithFhir<T>;
4 changes: 2 additions & 2 deletions src/cds-hooks/suggestion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUID } from "crypto";
import { SystemAction } from "./util";

export default class Suggestion implements Suggestion {
export default class CDSSuggestion implements CDSSuggestion {
/**
* Unique identifier of the card. MAY be used for auditing and logging cards
* and SHALL be included in any subsequent calls to the CDS service's feedback
Expand All @@ -28,7 +28,7 @@ export default class Suggestion implements Suggestion {
*/
actions?: SystemAction[];

constructor(options: Partial<Suggestion> & { label: string }) {
constructor(options: Partial<CDSSuggestion> & { label: string }) {
this.uuid = options.uuid || randomUUID();
this.label = options.label;
this.isRecommended = options.isRecommended;
Expand Down
16 changes: 8 additions & 8 deletions src/cds-hooks/util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Service } from "./index";
import { CDSService } from "./index";
import Ajv from "ajv";
import { ValidateFunction, ErrorObject } from "ajv";
import { HookRequest } from "./service";
import { CDSHookRequest } from "./service";

/**
* Validates that the hook request dynmically (depending on the hook and service at runtime):
Expand All @@ -12,7 +12,7 @@ import { HookRequest } from "./service";
* @param reply
* @returns Error | undefined
*/
export function validateHookRequest(hookRequest: HookRequest<Record<string, unknown>>, service: Service): Error | undefined {
export function validateHookRequest(hookRequest: CDSHookRequest<Record<string, unknown>>, service: CDSService): Error | undefined {
// @ts-expect-error hookRequest.hook needs to be co-erced into the type...
const context = validateContext(hookRequest, hookRequest.hook);
const prefetch = validatePrefetch(hookRequest, service);
Expand Down Expand Up @@ -64,7 +64,7 @@ function schemaErrorFormatter(errors: ErrorObject[], dataVar: string) {
* @param service
* @returns
*/
function validatePrefetch(request: HookRequest<Record<string, any>>, service: Service): ValidateFunction {
function validatePrefetch(request: CDSHookRequest<Record<string, any>>, service: CDSService): ValidateFunction {
const ajv = new Ajv({
removeAdditional: false,
useDefaults: true,
Expand All @@ -74,7 +74,7 @@ function validatePrefetch(request: HookRequest<Record<string, any>>, service: Se

const prefetch = request.prefetch;

function prefetchFor(service: Service) {
function prefetchFor(service: CDSService) {
// Service expects prefetch and no prefetch was sent
if (service.prefetch && prefetch == undefined) {
return {
Expand Down Expand Up @@ -118,7 +118,7 @@ function validatePrefetch(request: HookRequest<Record<string, any>>, service: Se
return compiled
}

function validateContext(request: HookRequest<Record<string, any>>, hook: Hooks): ValidateFunction {
function validateContext(request: CDSHookRequest<Record<string, any>>, hook: Hooks): ValidateFunction {
const ajv = new Ajv({
removeAdditional: true,
useDefaults: true,
Expand Down Expand Up @@ -233,11 +233,11 @@ function validateContext(request: HookRequest<Record<string, any>>, hook: Hooks)
return compiled
}

export function getService(services: Service[], id: string): Service | undefined {
export function getService(services: CDSService[], id: string): CDSService | undefined {
return services.find((service) => service.id == id)
}

export class NoDecisionResponse extends Error {}
export class CDSNoDecisionResponse extends Error {}

export interface FhirAuthorization {
/**
Expand Down
Loading