Skip to content
Merged
43 changes: 12 additions & 31 deletions plugins/functions/d2e-webapi/src/api/WebApiConceptSetAPI.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ConceptSetNameConflictError } from "../errors/ConceptSetErrors.ts";

const DEFAULT_WEBAPI_URL = "http://localhost:33001/WebAPI";

export interface IWebApiConceptSetHeader {
Expand Down Expand Up @@ -52,16 +54,6 @@ export interface IWebApiConcept {

const CONTROL_CHAR_REGEX = /[\x00-\x1F\x7F]/;

const assertNonNegativeInteger = (value: unknown, field: string): number => {
if (
typeof value !== "number" || !Number.isInteger(value) || value < 0 ||
value > Number.MAX_SAFE_INTEGER
) {
throw new Error(`Invalid ${field}: expected non-negative integer`);
}
return value;
};

const assertPositiveInteger = (value: unknown, field: string): number => {
if (
typeof value !== "number" || !Number.isInteger(value) || value <= 0 ||
Expand Down Expand Up @@ -224,6 +216,11 @@ export class WebApiConceptSetAPI {
});

if (!response.ok) {
// WebAPI has no duplicate-name check. The `uq_cs_name` constraint on
// `webapi.concept_set` rejects the insert and surfaces as a 409.
if (response.status === 409) {
throw new ConceptSetNameConflictError(payload.name);
}
throw new Error(
`Failed to create WebAPI concept set: ${response.status}`,
);
Expand Down Expand Up @@ -259,6 +256,11 @@ export class WebApiConceptSetAPI {
);

if (!response.ok) {
// A rename onto a name another concept set already holds trips the same
// `uq_cs_name` constraint as a create.
if (response.status === 409) {
throw new ConceptSetNameConflictError(payload.name);
}
throw new Error(
`Failed to update WebAPI concept set ${validatedId}: ${response.status}`,
);
Expand Down Expand Up @@ -318,25 +320,4 @@ export class WebApiConceptSetAPI {
);
}
}

async checkIfConceptSetExists(id: number, name: string): Promise<number> {
const validatedId = assertNonNegativeInteger(id, "id");
const validatedName = assertName(name);

const url = buildUrl(this.baseUrl, "conceptset", validatedId, "exists");
url.searchParams.set("name", validatedName);

const response = await fetch(url, {
method: "GET",
headers: buildHeaders(this.token),
});

if (!response.ok) {
throw new Error(
`Failed to check WebAPI concept set existence for ${validatedId}: ${response.status}`,
);
}

return response.json();
}
}
9 changes: 9 additions & 0 deletions plugins/functions/d2e-webapi/src/dto/conceptset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ export const ConceptSetInUseErrorDto = z.object({
});
export type IConceptSetInUseErrorDto = z.infer<typeof ConceptSetInUseErrorDto>;

export const ConceptSetNameConflictErrorDto = z.object({
error: z.literal("CONCEPT_SET_NAME_EXISTS"),
message: z.string(),
conceptSetName: z.string(),
});
export type IConceptSetNameConflictErrorDto = z.infer<
typeof ConceptSetNameConflictErrorDto
>;

export const IncludedConceptDto = z.object({
CONCEPT_ID: z.number(),
CONCEPT_NAME: z.string(),
Expand Down
14 changes: 14 additions & 0 deletions plugins/functions/d2e-webapi/src/errors/ConceptSetErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ export class ConceptSetInUseError extends Error {
}
}

/**
* Thrown when WebAPI rejects a concept set name that is already taken.
* WebAPI has no duplicate-name check of its own; the `uq_cs_name` unique
* constraint on `webapi.concept_set` rejects the write and WebAPI reports it
* as HTTP 409. Route handlers map this error back to a 409 so that the browser
* can show a duplicate-name message instead of a generic failure.
*/
export class ConceptSetNameConflictError extends Error {
constructor(public readonly conceptSetName: string) {
super(`A concept set named "${conceptSetName}" already exists`);
this.name = "ConceptSetNameConflictError";
}
}

/**
* Thrown when concept set validation fails (e.g., invalid ID format).
*/
Expand Down
76 changes: 57 additions & 19 deletions plugins/functions/d2e-webapi/src/routes/conceptset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import {
ConceptSetItemsResponseDto,
ConceptSetCreateDto,
ConceptSetInUseErrorDto,
ConceptSetNameConflictErrorDto,
IConceptSetCheckResponseDto,
IncludedConceptsRequestDto,
IncludedConceptsResponseDto,
} from "../dto/conceptset.ts";
import { ConceptSetInUseError } from "../errors/ConceptSetErrors.ts";
import {
ConceptSetInUseError,
ConceptSetNameConflictError,
} from "../errors/ConceptSetErrors.ts";

import {
getConceptSet,
Expand Down Expand Up @@ -55,10 +59,14 @@ export const conceptset: FastifyPluginAsyncZod = async function (app) {
"/",
{
schema: {
description: "Save a new concept set to the database",
description:
"Save a new concept set to the database. Returns 409 if the name is already taken.",
body: ConceptSetCreateDto,
tags: ["conceptset"],
response: { 200: ConceptSetResponseDto },
response: {
200: ConceptSetResponseDto,
409: ConceptSetNameConflictErrorDto,
},
security: [
{
bearerAuth: [],
Expand All @@ -68,12 +76,25 @@ export const conceptset: FastifyPluginAsyncZod = async function (app) {
},
},
async (req, res) => {
const results = await createConceptSet(
req.token,
req.datasetId,
req.body
);
res.send(results);
try {
const results = await createConceptSet(
req.token,
req.datasetId,
req.body
);
res.send(results);
} catch (error) {
if (error instanceof ConceptSetNameConflictError) {
res.status(409).send({
error: "CONCEPT_SET_NAME_EXISTS",
message:
`A concept set named "${error.conceptSetName}" already exists. Choose another name.`,
conceptSetName: error.conceptSetName,
});
return;
}
throw error;
}
}
);

Expand Down Expand Up @@ -155,11 +176,15 @@ export const conceptset: FastifyPluginAsyncZod = async function (app) {
"/:id",
{
schema: {
description: "Updates the concept set for the selected concept set.",
description:
"Updates the concept set for the selected concept set. Returns 409 if the name is already taken.",
tags: ["conceptset"],
params: z.object({ id: ConceptSetIdParamSchema }),
body: ConceptSetCreateDto,
response: { 200: z.boolean() },
response: {
200: z.boolean(),
409: ConceptSetNameConflictErrorDto,
},
security: [
{
bearerAuth: [],
Expand All @@ -170,13 +195,26 @@ export const conceptset: FastifyPluginAsyncZod = async function (app) {
},
async (req, res) => {
const { id } = req.params;
const results = await updateConceptSet(
req.token,
req.datasetId,
id,
req.body
);
res.send(results);
try {
const results = await updateConceptSet(
req.token,
req.datasetId,
id,
req.body
);
res.send(results);
} catch (error) {
if (error instanceof ConceptSetNameConflictError) {
res.status(409).send({
error: "CONCEPT_SET_NAME_EXISTS",
message:
`A concept set named "${error.conceptSetName}" already exists. Choose another name.`,
conceptSetName: error.conceptSetName,
});
return;
}
throw error;
}
}
);

Expand All @@ -185,7 +223,7 @@ export const conceptset: FastifyPluginAsyncZod = async function (app) {
{
schema: {
description:
"Check if a concept set with the same name exists in the WebAPIdatabase. The name is checked against the selected concept set IDto ensure that only the selected concept set ID has the name specified.",
"Check whether a concept set with the same name exists in the legacy (terminology) store. This is the only store probed here; the WebAPI store enforces name uniqueness with the uq_cs_name constraint and reports a duplicate as HTTP 409. The selected concept set ID is excluded so that only that set may hold the name.",
tags: ["conceptset"],
params: z.object({ id: ConceptSetIdParamSchema }),
querystring: z.object({ name: z.string() }),
Expand Down
Loading
Loading