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
74 changes: 56 additions & 18 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 Down
Loading
Loading