diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index ef79cb80e..bdcd93b0f 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -4567,6 +4567,64 @@ "summary": "Create deployment" } }, + "/v1/workspaces/{workspaceId}/deployments/name/{name}": { + "get": { + "operationId": "getDeploymentByName", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Name of the deployment", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeploymentWithVariablesAndSystems" + } + } + }, + "description": "OK response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Invalid request" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Resource not found" + } + }, + "summary": "Get deployment by name" + } + }, "/v1/workspaces/{workspaceId}/deployments/{deploymentId}": { "delete": { "operationId": "requestDeploymentDeletion", diff --git a/apps/api/openapi/paths/deployments.jsonnet b/apps/api/openapi/paths/deployments.jsonnet index dcfa2aca1..25b8f1a5a 100644 --- a/apps/api/openapi/paths/deployments.jsonnet +++ b/apps/api/openapi/paths/deployments.jsonnet @@ -32,6 +32,19 @@ local openapi = import '../lib/openapi.libsonnet'; + openapi.conflictResponse('Deployment name already exists in this workspace'), }, }, + '/v1/workspaces/{workspaceId}/deployments/name/{name}': { + get: { + summary: 'Get deployment by name', + operationId: 'getDeploymentByName', + parameters: [ + openapi.workspaceIdParam(), + openapi.stringParam('name', 'Name of the deployment'), + ], + responses: openapi.okResponse(openapi.schemaRef('DeploymentWithVariablesAndSystems')) + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + }, '/v1/workspaces/{workspaceId}/deployments/{deploymentId}': { get: { summary: 'Get deployment', diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 033bc5d2a..3d6139bd4 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -89,21 +89,9 @@ const listDeployments: AsyncTypedHandler< res.status(200).json(data); }; -const getDeployment: AsyncTypedHandler< - "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", - "get" -> = async (req, res) => { - const { workspaceId, deploymentId } = req.params; - - const dep = await db.query.deployment.findFirst({ - where: and( - eq(schema.deployment.id, deploymentId), - eq(schema.deployment.workspaceId, workspaceId), - ), - }); - - if (dep == null) throw new ApiError("Deployment not found", 404); - +const getDeploymentWithVariablesAndSystems = async ( + dep: typeof schema.deployment.$inferSelect, +) => { const systemRows = await db .select({ system: schema.system }) .from(schema.systemDeployment) @@ -111,12 +99,12 @@ const getDeployment: AsyncTypedHandler< schema.system, eq(schema.systemDeployment.systemId, schema.system.id), ) - .where(eq(schema.systemDeployment.deploymentId, deploymentId)); + .where(eq(schema.systemDeployment.deploymentId, dep.id)); const variables = await db .select() .from(schema.deploymentVariable) - .where(eq(schema.deploymentVariable.deploymentId, deploymentId)); + .where(eq(schema.deploymentVariable.deploymentId, dep.id)); const variableIds = variables.map((v) => v.id); const variableValues = @@ -142,7 +130,7 @@ const getDeployment: AsyncTypedHandler< valuesByVariableId.set(val.deploymentVariableId, arr); } - res.status(200).json({ + return { deployment: formatDeployment(dep), systems: systemRows.map((r) => formatSystem(r.system)), variables: variables.map((v) => ({ @@ -161,7 +149,43 @@ const getDeployment: AsyncTypedHandler< resourceSelector: parseSelector(val.resourceSelector), })), })), + }; +}; + +const getDeployment: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", + "get" +> = async (req, res) => { + const { workspaceId, deploymentId } = req.params; + + const dep = await db.query.deployment.findFirst({ + where: and( + eq(schema.deployment.id, deploymentId), + eq(schema.deployment.workspaceId, workspaceId), + ), }); + + if (dep == null) throw new ApiError("Deployment not found", 404); + + res.status(200).json(await getDeploymentWithVariablesAndSystems(dep)); +}; + +const getDeploymentByName: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/deployments/name/{name}", + "get" +> = async (req, res) => { + const { workspaceId, name } = req.params; + + const dep = await db.query.deployment.findFirst({ + where: and( + eq(schema.deployment.name, name), + eq(schema.deployment.workspaceId, workspaceId), + ), + }); + + if (dep == null) throw new ApiError("Deployment not found", 404); + + res.status(200).json(await getDeploymentWithVariablesAndSystems(dep)); }; const postDeployment: AsyncTypedHandler< @@ -505,6 +529,7 @@ const getDeploymentPlan: AsyncTypedHandler< export const deploymentsRouter = Router({ mergeParams: true }) .get("/", asyncHandler(listDeployments)) .post("/", asyncHandler(postDeployment)) + .get("/name/:name", asyncHandler(getDeploymentByName)) .get("/:deploymentId", asyncHandler(getDeployment)) .put("/:deploymentId", asyncHandler(upsertDeployment)) .delete("/:deploymentId", asyncHandler(deleteDeployment)) diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index aa3680041..a553b3ac9 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -173,6 +173,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/deployments/name/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get deployment by name */ + get: operations["getDeploymentByName"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/deployments/{deploymentId}": { parameters: { query?: never; @@ -3055,6 +3072,49 @@ export interface operations { }; }; }; + getDeploymentByName: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Name of the deployment */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeploymentWithVariablesAndSystems"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getDeployment: { parameters: { query?: never; diff --git a/e2e/tests/api/deployments.spec.ts b/e2e/tests/api/deployments.spec.ts index 0a459d353..2f5362e61 100644 --- a/e2e/tests/api/deployments.spec.ts +++ b/e2e/tests/api/deployments.spec.ts @@ -719,6 +719,55 @@ test.describe("Deployment API", () => { ]); }); + test("should get a deployment by name", async ({ api, workspace }) => { + const name = `deploy-by-name-${faker.string.alphanumeric(8)}`; + const createRes = await api.POST( + "/v1/workspaces/{workspaceId}/deployments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name, slug: name, description: "Fetch-by-name target" }, + }, + ); + expect(createRes.response.status).toBe(202); + const deploymentId = createRes.data!.id; + + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployments/name/{name}", + { + params: { path: { workspaceId: workspace.id, name } }, + }, + ); + + expect(getRes.response.status).toBe(200); + expect(getRes.data!.deployment.id).toBe(deploymentId); + expect(getRes.data!.deployment.name).toBe(name); + expect(getRes.data!.deployment.description).toBe("Fetch-by-name target"); + + await api.DELETE( + "/v1/workspaces/{workspaceId}/deployments/{deploymentId}", + { params: { path: { workspaceId: workspace.id, deploymentId } } }, + ); + }); + + test("should return 404 when getting a deployment by a non-existent name", async ({ + api, + workspace, + }) => { + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/deployments/name/{name}", + { + params: { + path: { + workspaceId: workspace.id, + name: `missing-${faker.string.alphanumeric(12)}`, + }, + }, + }, + ); + + expect(getRes.response.status).toBe(404); + }); + test("should return all deployments when no CEL filter is provided", async ({ api, workspace,