Skip to content

Commit

Permalink
fix: endless search for multiple-term queries
Browse files Browse the repository at this point in the history
This PR fixes "endless" searches or time-outs due to long multiple-term queries. This closes #38 .
  • Loading branch information
andreakreichgauer authored Apr 7, 2022
1 parent e8b560e commit ad59121
Show file tree
Hide file tree
Showing 13 changed files with 139 additions and 80 deletions.
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# ICD 10 Rest API (German Version)
# ICD-10 API (German Version)
API to search the german version of the 10th revision of the International Statistical Classification of Diseases Database.

[![GitHub release (latest by date)](https://img.shields.io/github/v/release/dot-base/icd-10-api)](https://github.com/dot-base/icd-10-api/releases)


## Quick Nav
1. [Production Deployment](#Production-Deployment)
1. [Usage](#Usage)
1. [Contributing](#Contributing)

## Production Deployment
Want an ICD 10 api of your own? The easiest way is to deploy our docker container. Just follow the steps below to get started.
## Usage
Want an ICD-10 api of your own? The easiest way is to deploy our docker container. Just follow the steps below to get started.

[![Docker Build Status](https://img.shields.io/badge/We%20love-Docker-blue?style=flat&logo=Docker)](https://github.com/orgs/dot-base/packages)

Expand All @@ -19,12 +19,44 @@ Want an ICD 10 api of your own? The easiest way is to deploy our docker containe


### Deployment
1. Set environment variables to configure the container:
```sh
export MAX_SEARCH_WORDS="6"
```
1. Start the container with a single command
```
docker run --name icd-10-api -p 3000:3000 -d ghcr.io/dot-base/icd-10-api:latest
```
1. Done and dusted 🎉. The ICD 10 rest api is available on port 3000.
1. Done and dusted 🎉. The ICD-10 api is available on port 3000.

## Configuration

### Environment Variables
| Variable Name | Default | Example |
| --- | --- | --- |
| MAX_SEARCH_WORDS | 6 | - |

## Considerations

### Pre-processing and multi-term searches
The ICD-10 api processes a search query by first splitting it into separate search terms as in the following example:

```
'Parkinson-Syndrom Primär' -> ['Parkinson', 'Syndrom', Primär]
'Parkinson G20.9 unspezifisch' -> ['Parkinson', 'G20.9', 'unspezifisch']
```
If a query consists of several terms, the ICD-10 api will assemble all combinations of these terms and order them by length:
```
'Parkinson-Syndrom Primär' -> ['Parkinson Syndrom Primär', 'Parkinson Syndrom', 'Parkinson Primär', 'Syndrom Primär', 'Parkinson', 'Syndrom', 'Primär']
```
The service will search for matches in descending order, meaning it will first search for the full term '*Parkinson Syndrom Primär*'. If no match was found, the search will proceed with '*Parkinson AND Syndrom*' '*Parkinson AND Primär*' '*Syndrom AND Primär*'. If the combination of two search terms results in one or several matches, the search will stop and return the result. Otherwise, it will proceed to search for each single term separately.
Due too performance and time-out reasons the default max. value for search terms is set to 6, but can be changed indiviually by setting `MAX_SEARCH_WORDS`.
### Prioritization of ICD-10 codes
Terms that match the ICD code pattern are handled with priority. If a query contains something like '*Parkinson G20*' or '*Parkinson G20.9*', the service will first try to find exact matches for these ICD codes. It will only search for further results matching 'Parkinson', if no matching ICD codes were found.
## Contributing
Expand Down
21 changes: 17 additions & 4 deletions src/controller/icd10Controller.ts → src/controller/icd10.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,43 @@ import Fuse from "fuse.js";
import { ICodeSystem_Concept } from "@ahryman40k/ts-fhir-types/lib/R4";
import CodeFilter from "@/services/codeFilter";
import TextFilter from "@/services/textFilter";
import HTTPError from "@/utils/HTTPError";

export class ICD10Controller {
private static icdRegex = new RegExp("[A-TV-Z][0-9][0-9].?[0-9A-TV-Z]{0,4}", "i");
private static icd10Regex = new RegExp("[A-TV-Z][0-9][0-9].?[0-9A-TV-Z]{0,4}", "i");
private static stripRegex = new RegExp("[ -]+");

public static getFiltered(searchstring: string): Fuse.FuseResult<ICodeSystem_Concept>[] {
const searchTerms: string[] = ICD10Controller.splitTerms(searchstring);
const icd10Codes: string[] = ICD10Controller.filterCodes(searchTerms);

/**
* If a query contains icd10 codes (e.g. G20.9),
* only codes are considered and remaining search terms are ignored
*/
if (icd10Codes.length > 0) {
const codeResponse = CodeFilter.initSearch(icd10Codes);
if (codeResponse.length > 0) return codeResponse;
}

if (searchTerms.length > Number(process.env.MAX_SEARCH_WORDS))
throw new HTTPError(
`Search query exceeded max. amount of ${process.env.MAX_SEARCH_WORDS} allowed terms.`,
400
);

const searchResult = TextFilter.initSearch(searchTerms);

// copy the results before removing extensions, otherwise
// we would change the actual database we are searching on
/**
* copy the results before removing extensions, otherwise
* we would change the actual dataset we are searching on
*/
const searchResultCopy = JSON.parse(JSON.stringify(searchResult));
return ICD10Controller.removeExtensions(searchResultCopy);
}

private static isICD10Code(str: string): boolean {
return ICD10Controller.icdRegex.test(str);
return ICD10Controller.icd10Regex.test(str);
}

private static filterCodes(terms: string[]): string[] {
Expand Down
10 changes: 10 additions & 0 deletions src/express.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import express from "express";
import ICD10Router from "@/routers/icd10";

const app = express();

app.use(express.json());

app.use("/api/icd10", ICD10Router);

export default app;
1 change: 0 additions & 1 deletion src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

const logger = console;

export default logger;
13 changes: 4 additions & 9 deletions src/model/ICD10gmCodesystem.ts → src/model/icd10CodeSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,15 @@ import io from "io-ts";
import icd10gm from "@/data/codesystem_icd10_gm_2020.json";
import logger from "@/logger";

export default class ICD10gm {
public static instance: ICD10gm;
class ICD10gm {
public codesystem: R4.ICodeSystem;
public processedCodesystem: R4.ICodeSystem;

private constructor() {
public constructor() {
this.codesystem = ICD10gm.initCodesystem();
this.processedCodesystem = ICD10gm.preProcessCodeSystem(this.codesystem);
}

public static getInstance(): ICD10gm {
if (!ICD10gm.instance) ICD10gm.instance = new ICD10gm();
logger.info("Loading and prefiltering ICD10gm Codesystem succeded");
return ICD10gm.instance;
}

private static initCodesystem(): R4.ICodeSystem {
const icd10gmDecoded = R4.RTTI_CodeSystem.decode(icd10gm);

Expand Down Expand Up @@ -85,3 +78,5 @@ export default class ICD10gm {
return concept;
}
}

export default new ICD10gm();
21 changes: 21 additions & 0 deletions src/routers/icd10.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import express from "express";
import { ICD10Controller } from "@/controller/icd10";
import HTTPError from "@/utils/HTTPError";

const router: express.Router = express.Router();

router.get("/", async (req: express.Request, res: express.Response) => {
if (!req.query.search)
return res.status(400).send("Request is missing a query parameter 'search'.").end();

try {
const icd10Codes = ICD10Controller.getFiltered(req.query.search as string);
return res.status(200).send(icd10Codes);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if (e instanceof HTTPError) res.status(e.status).send(e.message);
else res.status(500).send(e.message);
}
});

export default router;
18 changes: 0 additions & 18 deletions src/routes/icd10.ts

This file was deleted.

73 changes: 35 additions & 38 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import express from "express";
import { Express } from "express";
import express from "@/express";
import bodyParser from "body-parser";
import cors from "cors";
import * as Sentry from "@sentry/node";
import * as Tracing from "@sentry/tracing";

import icd10Router from "@/routes/icd10";
import ICD10gm from "@/model/ICD10gmCodesystem";
import logger from "@/logger";

class Icd10Api {
export default class Server {
private static get port(): string {
return process.env.PORT || "3000";
}
Expand All @@ -17,43 +14,43 @@ class Icd10Api {
return !!process.env.SENTRY_DSN && !!process.env.SENTRY_ENVIRONMENT;
}

private async startApiServer() {
const app: express.Application = express();

if (Icd10Api.sentryIsEnabled) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Tracing.Integrations.Express({ app }),
],
tracesSampleRate: 1.0,
environment: process.env.SENTRY_ENVIRONMENT,
});

app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
}

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cors());

app.use("/api/icd10", icd10Router);

if (Icd10Api.sentryIsEnabled) {
app.use(Sentry.Handlers.errorHandler());
}

app.listen(Icd10Api.port, () => {
logger.info(`Server listening on ${Icd10Api.port}`);
private static enableSentry(app: Express) {
if (!Server.sentryIsEnabled) return;
Sentry.init({
dsn: process.env.SENTRY_DSN,
integrations: [
new Sentry.Integrations.Http({ tracing: true }),
new Tracing.Integrations.Express({ app }),
],
tracesSampleRate: 1.0,
environment: process.env.SENTRY_ENVIRONMENT,
});

app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
app.use(Sentry.Handlers.errorHandler());
}

private static setDefaultEnvironmentVariables() {
process.env.MAX_SEARCH_WORDS = process.env.MAX_SEARCH_WORDS ?? "6";
}

constructor() {
ICD10gm.getInstance();
this.startApiServer();
}

private async startApiServer() {
express.use(bodyParser.urlencoded({ extended: true }));
express.use(bodyParser.json());
express.use(cors());

Server.setDefaultEnvironmentVariables();
Server.enableSentry(express);

express.listen(Server.port, () => {
console.log(`Server is listening on ${Server.port}`);
});
}
}

new Icd10Api();
new Server();
1 change: 1 addition & 0 deletions src/services/codeFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FuseSearch from "@/services/fuseSearch";

export default class CodeFilter extends Filter {
protected static keys: Fuse.FuseOptionKeyObject[] = [{ name: "code", weight: 1 }];

protected static queryOptions: QueryOptions = {
matchType: MatchType.exactMatch,
logicalOperator: LogicalOperator.OR,
Expand Down
4 changes: 2 additions & 2 deletions src/services/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ export default abstract class Filter extends FuseSearch {

/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
public static initSearch(terms: string[]): Fuse.FuseResult<ICodeSystem_Concept>[] {
throw new Error("Error: Called method 'search' on abstract class Filter.");
throw new Error("Error: Called method 'initSearch' on abstract class Filter.");
}

/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
protected static getQuery(queryStr: string[] | string): void {
throw new Error("Error: Called method 'setQuery' on abstract class Filter.");
throw new Error("Error: Called method 'getQuery' on abstract class Filter.");
}
}
5 changes: 2 additions & 3 deletions src/services/fuseSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ICD10gm from "@/model/ICD10gmCodesystem";
import ICD10gm from "@/model/icd10CodeSystem";
import Fuse from "fuse.js";
import { ICodeSystem_Concept } from "@ahryman40k/ts-fhir-types/lib/R4";
import { QueryOptions } from "@/types/queryOptions";
Expand All @@ -21,8 +21,7 @@ export default class FuseSearch {
keys: Fuse.FuseOptionKeyObject[],
query: Fuse.Expression[]
): Fuse.FuseResult<ICodeSystem_Concept>[] {
const icd10 = ICD10gm.getInstance();
const base = icd10.processedCodesystem?.concept ?? [];
const base = ICD10gm.processedCodesystem?.concept ?? [];
const options = FuseSearch.getOptions(keys);
const index = Fuse.createIndex(keys, base);
const fuse = new Fuse(base, options, index);
Expand Down
1 change: 1 addition & 0 deletions src/services/textFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default class TextFilter extends Filter {
{ name: "extension.valueString", weight: 0.6 },
{ name: "modifierExtension.valueString", weight: 0.4 },
];

protected static queryOptions: QueryOptions = {
matchType: MatchType.fuzzy,
logicalOperator: LogicalOperator.AND,
Expand Down
9 changes: 9 additions & 0 deletions src/utils/HTTPError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default class HTTPError extends Error {
public status: number;

constructor(message: string, status: number) {
super(message);
this.status = status;
}
}

0 comments on commit ad59121

Please sign in to comment.