Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions apps/api/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,74 @@
],
"type": "object"
},
"ListResourcesFilters": {
"properties": {
"identifiers": {
"items": {
"type": "string"
},
"type": "array"
},
"kinds": {
"items": {
"type": "string"
},
"type": "array"
},
"limit": {
"default": 500,
"maximum": 1000,
"minimum": 1,
"type": "integer"
},
"metadata": {
"additionalProperties": {
"type": "string"
},
"description": "Exact metadata key/value matches",
"type": "object"
},
"offset": {
"default": 0,
"minimum": 0,
"type": "integer"
},
"order": {
"default": "asc",
"enum": [
"asc",
"desc"
],
"type": "string"
},
"providerIds": {
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"description": "Text search on name or identifier",
"type": "string"
},
"sortBy": {
"enum": [
"createdAt",
"updatedAt",
"name",
"kind"
],
"type": "string"
},
"versions": {
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"LiteralValue": {
"oneOf": [
{
Expand Down Expand Up @@ -7946,6 +8014,85 @@
]
}
},
"/v1/workspaces/{workspaceId}/resources/search": {
"post": {
"description": "Returns a paginated list of resources matching the given filters.",
"operationId": "searchResources",
"parameters": [
{
"description": "ID of the workspace",
"in": "path",
"name": "workspaceId",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListResourcesFilters"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/Resource"
},
"type": "array"
},
"limit": {
"description": "Maximum number of items returned",
"type": "integer"
},
"offset": {
"description": "Number of items skipped",
"type": "integer"
},
"total": {
"description": "Total number of items available",
"type": "integer"
}
},
"required": [
"items",
"total",
"limit",
"offset"
],
"type": "object"
}
}
},
"description": "Matching resources"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Invalid request"
}
},
"summary": "Search resources",
"tags": [
"Resources"
]
}
},
"/v1/workspaces/{workspaceId}/resources/{resourceIdentifier}/release-targets/deployment/{deploymentId}": {
"get": {
"description": "Returns a release target for a resource in a deployment.",
Expand Down
22 changes: 22 additions & 0 deletions apps/api/openapi/paths/resources.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ local openapi = import '../lib/openapi.libsonnet';
},
},

'/v1/workspaces/{workspaceId}/resources/search': {
post: {
tags: ['Resources'],
summary: 'Search resources',
operationId: 'searchResources',
description: 'Returns a paginated list of resources matching the given filters.',
parameters: [
openapi.workspaceIdParam(),
],
requestBody: {
required: true,
content: {
'application/json': {
schema: openapi.schemaRef('ListResourcesFilters'),
},
},
},
responses: openapi.paginatedResponse(openapi.schemaRef('Resource'), 'Matching resources')
+ openapi.badRequestResponse(),
},
},

'/v1/workspaces/{workspaceId}/resources/identifier/{identifier}': {
get: {
tags: ['Resources'],
Expand Down
39 changes: 39 additions & 0 deletions apps/api/openapi/schemas/resources.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,43 @@ local openapi = import '../lib/openapi.libsonnet';
message: { type: 'string' },
},
},

ListResourcesFilters: {
type: 'object',
properties: {
providerIds: { type: 'array', items: { type: 'string' } },
versions: { type: 'array', items: { type: 'string' } },
identifiers: { type: 'array', items: { type: 'string' } },
query: { type: 'string', description: 'Text search on name or identifier' },
kinds: {
type: 'array',
items: { type: 'string' },
},
limit: {
type: 'integer',
minimum: 1,
maximum: 1000,
default: 500,
},
offset: {
type: 'integer',
minimum: 0,
default: 0,
},
metadata: {
type: 'object',
additionalProperties: { type: 'string' },
description: 'Exact metadata key/value matches',
},
sortBy: {
type: 'string',
enum: ['createdAt', 'updatedAt', 'name', 'kind'],
},
order: {
type: 'string',
enum: ['asc', 'desc'],
default: 'asc',
},
},
},
}
109 changes: 108 additions & 1 deletion apps/api/src/routes/v1/workspaces/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { ApiError, asyncHandler } from "@/types/api.js";
import { evaluate } from "cel-js";
import { Router } from "express";

import { and, asc, count, eq, takeFirst } from "@ctrlplane/db";
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
or,
sql,
takeFirst,
} from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import {
enqueueManyDeploymentSelectorEval,
Expand Down Expand Up @@ -329,8 +340,104 @@ const getReleaseTargetForResourceInDeployment: AsyncTypedHandler<
});
};

const searchResources: AsyncTypedHandler<
"/v1/workspaces/{workspaceId}/resources/search",
"post"
> = async (req, res) => {
const { workspaceId } = req.params;

const {
providerIds,
versions,
identifiers,
query,
kinds,
limit,
offset,
metadata,
sortBy,
order,
} = req.body;

if (!Number.isInteger(limit) || limit < 0) {
res.status(400).json({ error: "`limit` must be a non-negative integer" });
return;
}

if (!Number.isInteger(offset) || offset < 0) {
res.status(400).json({ error: "`offset` must be a non-negative integer" });
return;
}

const escapedQuery = query != null ? query.replace(/[%_\\]/g, "\\$&") : null;

function isDefined<T>(value: T | null | undefined): value is T {
return value != null;
}

const conditions = [
eq(schema.resource.workspaceId, workspaceId),
providerIds?.length
? inArray(schema.resource.providerId, providerIds)
: undefined,
versions?.length ? inArray(schema.resource.version, versions) : undefined,
identifiers?.length
? inArray(schema.resource.identifier, identifiers)
: undefined,
escapedQuery != null
? or(
ilike(schema.resource.name, `%${escapedQuery}%`),
ilike(schema.resource.identifier, `%${escapedQuery}%`),
)
: undefined,
kinds?.length ? inArray(schema.resource.kind, kinds) : undefined,
...(metadata
? Object.entries(metadata).map(
([key, value]) =>
sql`${schema.resource.metadata}->>${key} = ${value}`,
)
: []),
].filter(isDefined);

const orderCol =
sortBy === "updatedAt"
? schema.resource.updatedAt
: sortBy === "name"
? schema.resource.name
: sortBy === "kind"
? schema.resource.kind
: schema.resource.createdAt;

const orderFn = order === "desc" ? desc : asc;

const [countResult, rows] = await Promise.all([
db
.select({ total: count() })
.from(schema.resource)
.where(and(...conditions))
.then((r) => r[0]),
db
.select()
.from(schema.resource)
.where(and(...conditions))
.orderBy(orderFn(orderCol), orderFn(schema.resource.identifier))
.limit(limit)
.offset(offset),
]);

const total = countResult?.total ?? 0;

res.status(200).json({
items: rows.map(toResourceResponse),
total,
limit,
offset,
});
};

export const resourceRouter = Router({ mergeParams: true })
.get("/", asyncHandler(listResources))
.post("/search", asyncHandler(searchResources))
.get("/identifier/:identifier", asyncHandler(getResourceByIdentifier))
.put("/identifier/:identifier", asyncHandler(upsertResourceByIdentifier))
.delete("/identifier/:identifier", asyncHandler(deleteResourceByIdentifier))
Expand Down
Loading
Loading