From a6584b66e5fa84e18d72c09b6feff325bdcad088 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 24 Apr 2026 13:19:12 -0700 Subject: [PATCH] chore: get env by name endpoint --- apps/api/openapi/openapi.json | 58 ++++++++ apps/api/openapi/paths/environments.jsonnet | 13 ++ .../src/routes/v1/workspaces/environments.ts | 59 +++++--- apps/api/src/types/openapi.ts | 60 +++++++++ e2e/api/schema.ts | 127 +++++++++++++++++- e2e/tests/api/environments.spec.ts | 49 +++++++ 6 files changed, 347 insertions(+), 19 deletions(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 0b2e0afc7..ef79cb80e 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -5362,6 +5362,64 @@ "summary": "Create environment" } }, + "/v1/workspaces/{workspaceId}/environments/name/{name}": { + "get": { + "operationId": "getEnvironmentByName", + "parameters": [ + { + "description": "ID of the workspace", + "in": "path", + "name": "workspaceId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Name of the environment", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnvironmentWithSystems" + } + } + }, + "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 environment by name" + } + }, "/v1/workspaces/{workspaceId}/environments/{environmentId}": { "delete": { "operationId": "requestEnvironmentDeletion", diff --git a/apps/api/openapi/paths/environments.jsonnet b/apps/api/openapi/paths/environments.jsonnet index 01824485b..1659eebe6 100644 --- a/apps/api/openapi/paths/environments.jsonnet +++ b/apps/api/openapi/paths/environments.jsonnet @@ -31,6 +31,19 @@ local openapi = import '../lib/openapi.libsonnet'; + openapi.conflictResponse('Environment name already exists in this workspace'), }, }, + '/v1/workspaces/{workspaceId}/environments/name/{name}': { + get: { + summary: 'Get environment by name', + operationId: 'getEnvironmentByName', + parameters: [ + openapi.workspaceIdParam(), + openapi.stringParam('name', 'Name of the environment'), + ], + responses: openapi.okResponse(openapi.schemaRef('EnvironmentWithSystems')) + + openapi.notFoundResponse() + + openapi.badRequestResponse(), + }, + }, '/v1/workspaces/{workspaceId}/environments/{environmentId}': { get: { summary: 'Get environment', diff --git a/apps/api/src/routes/v1/workspaces/environments.ts b/apps/api/src/routes/v1/workspaces/environments.ts index 72498ec34..7d6573ec1 100644 --- a/apps/api/src/routes/v1/workspaces/environments.ts +++ b/apps/api/src/routes/v1/workspaces/environments.ts @@ -58,21 +58,9 @@ const listEnvironments: AsyncTypedHandler< }); }; -const getEnvironment: AsyncTypedHandler< - "/v1/workspaces/{workspaceId}/environments/{environmentId}", - "get" -> = async (req, res) => { - const { workspaceId, environmentId } = req.params; - - const env = await db.query.environment.findFirst({ - where: and( - eq(schema.environment.id, environmentId), - eq(schema.environment.workspaceId, workspaceId), - ), - }); - - if (env == null) throw new ApiError("Environment not found", 404); - +const getEnvironmentWithSystems = async ( + env: typeof schema.environment.$inferSelect, +) => { const systemRows = await db .select({ system: schema.system }) .from(schema.systemEnvironment) @@ -80,7 +68,7 @@ const getEnvironment: AsyncTypedHandler< schema.system, eq(schema.systemEnvironment.systemId, schema.system.id), ) - .where(eq(schema.systemEnvironment.environmentId, environmentId)); + .where(eq(schema.systemEnvironment.environmentId, env.id)); const systems = systemRows.map((r) => ({ id: r.system.id, @@ -91,7 +79,43 @@ const getEnvironment: AsyncTypedHandler< metadata: r.system.metadata, })); - res.status(200).json({ ...formatEnvironment(env), systems }); + return { ...formatEnvironment(env), systems }; +}; + +const getEnvironment: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/environments/{environmentId}", + "get" +> = async (req, res) => { + const { workspaceId, environmentId } = req.params; + + const env = await db.query.environment.findFirst({ + where: and( + eq(schema.environment.id, environmentId), + eq(schema.environment.workspaceId, workspaceId), + ), + }); + + if (env == null) throw new ApiError("Environment not found", 404); + + res.status(200).json(await getEnvironmentWithSystems(env)); +}; + +const getEnvironmentByName: AsyncTypedHandler< + "/v1/workspaces/{workspaceId}/environments/name/{name}", + "get" +> = async (req, res) => { + const { workspaceId, name } = req.params; + + const env = await db.query.environment.findFirst({ + where: and( + eq(schema.environment.name, name), + eq(schema.environment.workspaceId, workspaceId), + ), + }); + + if (env == null) throw new ApiError("Environment not found", 404); + + res.status(200).json(await getEnvironmentWithSystems(env)); }; const deleteEnvironment: AsyncTypedHandler< @@ -207,6 +231,7 @@ export const upsertEnvironmentById: AsyncTypedHandler< export const environmentsRouter = Router({ mergeParams: true }) .get("/", asyncHandler(listEnvironments)) .post("/", asyncHandler(createEnvironment)) + .get("/name/:name", asyncHandler(getEnvironmentByName)) .get("/:environmentId", asyncHandler(getEnvironment)) .put("/:environmentId", asyncHandler(upsertEnvironmentById)) .delete("/:environmentId", asyncHandler(deleteEnvironment)); diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index f0c5a1bd2..aa3680041 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -305,6 +305,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/environments/name/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get environment by name */ + get: operations["getEnvironmentByName"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/environments/{environmentId}": { parameters: { query?: never; @@ -3527,6 +3544,49 @@ export interface operations { }; }; }; + getEnvironmentByName: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Name of the environment */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvironmentWithSystems"]; + }; + }; + /** @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"]; + }; + }; + }; + }; getEnvironment: { parameters: { query?: never; diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index c2d090ab0..aa3680041 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -305,6 +305,23 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/workspaces/{workspaceId}/environments/name/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get environment by name */ + get: operations["getEnvironmentByName"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/workspaces/{workspaceId}/environments/{environmentId}": { parameters: { query?: never; @@ -1296,7 +1313,6 @@ export interface components { message: string; }; DeploymentVariable: { - defaultValue?: components["schemas"]["LiteralValue"]; deploymentId: string; description?: string; id: string; @@ -1974,7 +1990,6 @@ export interface components { slug: string; }; UpsertDeploymentVariableRequest: { - defaultValue?: components["schemas"]["LiteralValue"]; deploymentId: string; description?: string; key: string; @@ -3020,6 +3035,24 @@ export interface operations { "application/json": components["schemas"]["DeploymentRequestAccepted"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Deployment name already exists in this workspace */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getDeployment: { @@ -3092,6 +3125,24 @@ export interface operations { "application/json": components["schemas"]["DeploymentRequestAccepted"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Deployment name already exists in this workspace */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; }; requestDeploymentDeletion: { @@ -3285,6 +3336,8 @@ export interface operations { offset?: number; /** @description Sort order for results */ order?: "asc" | "desc"; + /** @description CEL expression to filter the results */ + cel?: string; }; header?: never; path: { @@ -3471,6 +3524,67 @@ export interface operations { "application/json": components["schemas"]["EnvironmentRequestAccepted"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Environment name already exists in this workspace */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getEnvironmentByName: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the workspace */ + workspaceId: string; + /** @description Name of the environment */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvironmentWithSystems"]; + }; + }; + /** @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"]; + }; + }; }; }; getEnvironment: { @@ -3561,6 +3675,15 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; + /** @description Environment name already exists in this workspace */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; }; }; requestEnvironmentDeletion: { diff --git a/e2e/tests/api/environments.spec.ts b/e2e/tests/api/environments.spec.ts index 0649216d3..199323a36 100644 --- a/e2e/tests/api/environments.spec.ts +++ b/e2e/tests/api/environments.spec.ts @@ -209,6 +209,55 @@ test.describe("Environment API", () => { expect(getRes.response.status).toBe(404); }); + test("should get an environment by name", async ({ api, workspace }) => { + const name = `env-by-name-${faker.string.alphanumeric(8)}`; + const createRes = await api.POST( + "/v1/workspaces/{workspaceId}/environments", + { + params: { path: { workspaceId: workspace.id } }, + body: { name, description: "Fetch-by-name target" }, + }, + ); + expect(createRes.response.status).toBe(202); + const environmentId = createRes.data!.id; + + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/environments/name/{name}", + { + params: { path: { workspaceId: workspace.id, name } }, + }, + ); + + expect(getRes.response.status).toBe(200); + expect(getRes.data!.id).toBe(environmentId); + expect(getRes.data!.name).toBe(name); + expect(getRes.data!.description).toBe("Fetch-by-name target"); + + await api.DELETE( + "/v1/workspaces/{workspaceId}/environments/{environmentId}", + { params: { path: { workspaceId: workspace.id, environmentId } } }, + ); + }); + + test("should return 404 when getting an environment by a non-existent name", async ({ + api, + workspace, + }) => { + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/environments/name/{name}", + { + params: { + path: { + workspaceId: workspace.id, + name: `missing-${faker.string.alphanumeric(12)}`, + }, + }, + }, + ); + + expect(getRes.response.status).toBe(404); + }); + test("should return 404 for a non-existent environment", async ({ api, workspace,