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

Commit

Permalink
Migrate CDS Hooks + Rest modules to fastify-plugin architecture (#86)
Browse files Browse the repository at this point in the history
- Deprecate Config
- Deprecate Http module
- Replace 'mount' interface/default export on CDS Hooks and Rest API modules with fastify-plugins
- Rename several of the objects in the CDS Hooks namespace so they are prefixed with CDS
- Split fixture servers
  • Loading branch information
jdjkelly authored Sep 22, 2021
1 parent c0f96a9 commit a21843f
Show file tree
Hide file tree
Showing 25 changed files with 174 additions and 196 deletions.
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

0 comments on commit a21843f

Please sign in to comment.