Skip to content

Commit 749ba45

Browse files
authored
Merge pull request #87 from bcgov/test
Promote test to main
2 parents 87f3707 + f4df792 commit 749ba45

File tree

16 files changed

+1575
-1992
lines changed

16 files changed

+1575
-1992
lines changed

.github/workflows/graphemes_api_build.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ jobs:
5555

5656
# Use the GHCR builder action
5757
- name: Build and push Docker image to GHCR
58-
uses: bcgov-nr/action-builder-ghcr@ace71f7a527ca6fc43c15c7806314be5a4579d2c # v.2.3.0
58+
uses: bcgov-nr/action-builder-ghcr@fd17bc1cbb16a60514e0df3966d42dff9fc232bc # v4.0.0
5959
with:
6060
package: graphemes-api
61-
tag: ${{ env.tag }}
61+
tags: ${{ env.tag }}
6262
triggers: graphemes-api/**
6363
build_context: ./graphemes-api
6464
build_file: ./graphemes-api/Dockerfile

graphemes-api/package-lock.json

Lines changed: 63 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

graphemes-api/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,24 @@
1313
"test": "npx vitest"
1414
},
1515
"dependencies": {
16+
"any-ascii": "^0.3.2",
17+
"cors": "^2.8.5",
1618
"dotenv": "^16.5.0",
1719
"express": "^5.1.0",
1820
"express-rate-limit": "^7.5.0",
21+
"js-levenshtein-esm": "^2.0.0",
1922
"knex": "^3.1.0",
2023
"pg": "^8.16.0",
21-
"zod": "^3.25.7"
24+
"zod": "^3.25.28"
2225
},
2326
"devDependencies": {
2427
"@eslint/js": "^9.27.0",
28+
"@types/cors": "^2.8.18",
2529
"@types/express": "^5.0.2",
26-
"@types/node": "^22.15.20",
30+
"@types/node": "^22.15.21",
2731
"@types/supertest": "^6.0.3",
2832
"eslint": "^9.27.0",
29-
"globals": "^16.1.0",
33+
"globals": "^16.2.0",
3034
"nodemon": "^3.1.9",
3135
"prettier": "^3.5.3",
3236
"supertest": "^7.1.1",

graphemes-api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import dotenv from "dotenv";
22
dotenv.config();
33
import express from "express";
4+
import cors from "cors";
45
import rateLimit from "express-rate-limit";
56

67
import router from "./routes/index.js";
@@ -20,6 +21,7 @@ const limiter = rateLimit({
2021

2122
// Middleware
2223
app.use(express.json());
24+
app.use(cors());
2325
app.use(limiter);
2426

2527
// Routes
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Router } from "express";
22

33
import languagesRouter from "./languages/index.js";
4+
import matchRouter from "./match/index.js";
45

56
const v1Router = Router();
67
v1Router.use("/languages", languagesRouter);
8+
v1Router.use("/match", matchRouter);
79

810
export default v1Router;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Router } from "express";
2+
3+
import compareValues from "../../../../utils/compareValues.js";
4+
5+
const matchRouter = Router();
6+
7+
matchRouter.post("/", async (req, res) => {
8+
// Check the request body for values to be compared (`value1` and `value2`).
9+
if (!req.body || Object.keys(req.body).length === 0) {
10+
res.status(400).json({
11+
error: "Bad Request",
12+
message: "Request body is required",
13+
statusCode: 400,
14+
});
15+
return;
16+
}
17+
18+
if (!req.body.value1 || !req.body.value2) {
19+
res.status(400).json({
20+
error: "Bad Request",
21+
message: "Request body requires values",
22+
statusCode: 400,
23+
});
24+
return;
25+
}
26+
27+
const { value1, value2, threshold } = req.body;
28+
29+
// Check for numeric threshold value and default to 0.9 if one isn't present.
30+
const numericThreshold = parseFloat(threshold);
31+
const comparison = compareValues(
32+
value1,
33+
value2,
34+
!isNaN(numericThreshold) ? numericThreshold : 0.9
35+
);
36+
37+
res.status(200).json({
38+
value1: comparison.comparisonValue1,
39+
value2: comparison.comparisonValue2,
40+
threshold: comparison.threshold,
41+
ratio: comparison.ratio,
42+
isMatch: comparison.isMatch,
43+
});
44+
});
45+
46+
export default matchRouter;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import compareValues from "./compareValues.js";
4+
5+
describe("compareValues()", () => {
6+
// Ratio of 0.5714285714285714, not a match given the default threshold 0.9
7+
const resultWithDefaultThreshold = compareValues("kitten", "sitting");
8+
it("returns an object with the expected keys", () => {
9+
const keys = Object.keys(resultWithDefaultThreshold);
10+
expect(keys.length).toBe(5);
11+
expect(keys.includes("comparisonValue1")).toBeTruthy();
12+
expect(keys.includes("comparisonValue2")).toBeTruthy();
13+
expect(keys.includes("threshold")).toBeTruthy();
14+
expect(keys.includes("ratio")).toBeTruthy();
15+
expect(keys.includes("isMatch")).toBeTruthy();
16+
});
17+
it("reports the default threshold value used when one isn't passed", () => {
18+
expect(resultWithDefaultThreshold.threshold).toBe(0.9);
19+
});
20+
it("reports the Levenshtein ratio of the values being compared", () => {
21+
expect(resultWithDefaultThreshold.ratio.toFixed(2)).toBe("0.57");
22+
});
23+
it("reports an accurate boolean isMatch for the values being compared", () => {
24+
expect(resultWithDefaultThreshold.isMatch).toBeFalsy();
25+
});
26+
27+
// Ratio of 0.5714285714285714, a match given the specific threshold 0.5
28+
const resultWithSpecificThreshold = compareValues("kitten", "sitting", 0.5);
29+
it("reports the specified threshold value used when one is passed", () => {
30+
expect(resultWithSpecificThreshold.threshold).toBe(0.5);
31+
});
32+
it("reports an accurate boolean isMatch for the values being compared with a low enough threshold", () => {
33+
expect(resultWithSpecificThreshold.isMatch).toBeTruthy();
34+
});
35+
36+
// Specific BC Parks names with known match booleans
37+
const resultParks1 = compareValues("Aa Tlein Teix’i", "A Téix'gi Aan Tlein");
38+
it("is not a match for: Aa Tlein Teix’i, A Téix'gi Aan Tlein", () => {
39+
expect(resultParks1.isMatch).toBeFalsy();
40+
});
41+
const resultParks2 = compareValues("Hakai LÚxvbálís", "Hakai Lúxvbálís");
42+
it("is a match for: Hakai LÚxvbálís, Hakai Lúxvbálís", () => {
43+
expect(resultParks2.isMatch).toBeTruthy();
44+
});
45+
const resultsParks3 = compareValues("Ẁaẁley", "Ẁaẁaƛ");
46+
it("is not a match for: Ẁaẁley, Ẁaẁaƛ", () => {
47+
expect(resultsParks3.isMatch).toBeFalsy();
48+
});
49+
const resultsParks4 = compareValues("Xʷakʷəᕈnaxdəᕈma", "Xʷak̓ʷəʔnaxdəʔma");
50+
it("is not a match for: Xʷakʷəᕈnaxdəᕈma, Xʷak̓ʷəʔnaxdəʔma", () => {
51+
expect(resultsParks4.isMatch).toBeFalsy();
52+
});
53+
const resultsPark5 = compareValues("ɂNacinuxʷ", "ʔNacinuxʷ");
54+
it("is a match for: ɂNacinuxʷ, ʔNacinuxʷ", () => {
55+
expect(resultsPark5.isMatch).toBeTruthy();
56+
});
57+
58+
it("whitespace is trimmed as expected", () => {
59+
const result = compareValues("dog", " dog ");
60+
expect(result.comparisonValue1).toBe("dog");
61+
expect(result.comparisonValue2).toBe("dog");
62+
expect(result.ratio).toBe(1);
63+
expect(result.isMatch).toBeTruthy();
64+
});
65+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import anyAscii from "any-ascii";
2+
import levenshteinDistance from "js-levenshtein-esm";
3+
4+
import levenshteinRatio from "./levenshtein-ratio.js";
5+
6+
interface ComparisonDetails {
7+
/** First string value for comparison. */
8+
comparisonValue1: string;
9+
/** Second string value for comparison. */
10+
comparisonValue2: string;
11+
/** Levenshtein ratio threshold to check for a match. */
12+
threshold: number;
13+
/** Levenshtein ratio to compare to the threshold. */
14+
ratio: number;
15+
/** Whether the strings match based on the given threshold. */
16+
isMatch: boolean;
17+
}
18+
19+
/**
20+
* Given two strings to compare and an optional Levenshtein threshold (which
21+
* defaults to `0.9`), returns an object containing a `ratio` number and
22+
* `isMatch` boolean indicating whether the strings are considered to be a match.
23+
*/
24+
export default function compareValues(
25+
value1: string,
26+
value2: string,
27+
threshold: number = 0.9
28+
): ComparisonDetails {
29+
// Trim whitespace from the ends of the input values and cast to uppercase.
30+
const capitalizedValue1 = value1.trim().toUpperCase();
31+
const capitalizedValue2 = value2.trim().toUpperCase();
32+
33+
// Convert uppercase values to ASCII.
34+
const ascii1 = anyAscii(capitalizedValue1);
35+
const ascii2 = anyAscii(capitalizedValue2);
36+
37+
// Calculate Levenshtein distance of the ASCII values.
38+
const distance = levenshteinDistance(ascii1, ascii2);
39+
40+
// Calculate Levenshtein ratio given the distance.
41+
const ratio = levenshteinRatio(ascii1, ascii2, distance);
42+
43+
return {
44+
comparisonValue1: value1.trim(),
45+
comparisonValue2: value2.trim(),
46+
threshold,
47+
ratio,
48+
isMatch: ratio >= threshold,
49+
};
50+
}

0 commit comments

Comments
 (0)