UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

482 lines (481 loc) 18.8 kB
import { ErrorCode, ForbiddenError, InvalidPathParameterError, InvalidPayloadError, isDirectusError, } from '@directus/errors'; import { DEPLOYMENT_PROVIDER_TYPES } from '@directus/types'; import express from 'express'; import Joi from 'joi'; import getDatabase from '../database/index.js'; import { respond } from '../middleware/respond.js'; import useCollection from '../middleware/use-collection.js'; import { validateBatch } from '../middleware/validate-batch.js'; import { DeploymentProjectsService } from '../services/deployment-projects.js'; import { DeploymentRunsService } from '../services/deployment-runs.js'; import { DeploymentService } from '../services/deployment.js'; import { MetaService } from '../services/meta.js'; import asyncHandler from '../utils/async-handler.js'; import { transaction } from '../utils/transaction.js'; const router = express.Router(); router.use(useCollection('directus_deployments')); // Require admin access for all deployment routes router.use((_req, _res, next) => { if (_req.accountability && _req.accountability.admin !== true) { throw new ForbiddenError(); } return next(); }); // Validate provider parameter const validateProvider = (provider) => { return DEPLOYMENT_PROVIDER_TYPES.includes(provider); }; // Validation schema for creating/updating deployment const deploymentSchema = Joi.object({ provider: Joi.string() .valid(...DEPLOYMENT_PROVIDER_TYPES) .required(), credentials: Joi.object().required(), options: Joi.object(), }).unknown(); // Create deployment config router.post('/', asyncHandler(async (req, res, next) => { const { error } = deploymentSchema.validate(req.body); if (error) { throw new InvalidPayloadError({ reason: error.message }); } const db = getDatabase(); const item = await transaction(db, async (trx) => { const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, knex: trx, }); const key = await service.createOne({ provider: req.body.provider, credentials: req.body.credentials, options: req.body.options, }); return service.readOne(key, req.sanitizedQuery); }); res.locals['payload'] = { data: item }; return next(); }), respond); // Read all deployment configs const readHandler = asyncHandler(async (req, res, next) => { const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, }); const records = await service.readByQuery(req.sanitizedQuery); const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery); res.locals['payload'] = { data: records || null, meta }; return next(); }); router.get('/', validateBatch('read'), readHandler, respond); router.search('/', validateBatch('read'), readHandler, respond); // Read single deployment config by provider router.get('/:provider', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const record = await service.readByProvider(provider, req.sanitizedQuery); res.locals['payload'] = { data: record || null }; return next(); }), respond); // List projects from provider (for config/selection) router.get('/:provider/projects', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); // Get provider config to find deployment ID const deployment = await service.readByProvider(provider); // Get projects from provider (with cache) const { data: providerProjects, remainingTTL } = await service.listProviderProjects(provider); // Get selected projects from DB const selectedProjects = await projectsService.readByQuery({ filter: { deployment: { _eq: deployment.id } }, }); // Map by external_id for quick lookup const selectedMap = new Map(selectedProjects.map((p) => [p.external_id, p])); // Sync names from provider const namesToUpdate = selectedProjects .map((dbProject) => { const providerProject = providerProjects.find((p) => p.id === dbProject.external_id); if (providerProject && providerProject.name !== dbProject.name) { return { id: dbProject.id, name: providerProject.name }; } return null; }) .filter((update) => update !== null); if (namesToUpdate.length > 0) { await projectsService.updateBatch(namesToUpdate); } // Merge with DB structure (id !== null means selected) const projects = providerProjects.map((project) => { return { id: selectedMap.get(project.id)?.id ?? null, external_id: project.id, name: project.name, deployable: project.deployable, framework: project.framework, }; }); // Pass remaining TTL for response headers res.locals['cache'] = false; res.locals['cacheTTL'] = remainingTTL; res.locals['payload'] = { data: projects }; return next(); }), respond); // Get single project details router.get('/:provider/projects/:id', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; const projectId = req.params['id']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); // Get project from DB (validates it exists and is selected) const project = await projectsService.readOne(projectId); // Fetch details from provider using external_id (with cache) const { data: details, remainingTTL } = await service.getProviderProject(provider, project.external_id); // Pass remaining TTL for response headers res.locals['cache'] = false; res.locals['cacheTTL'] = remainingTTL; res.locals['payload'] = { data: { ...details, id: project.id, external_id: project.external_id, }, }; return next(); }), respond); // Update selected projects const updateProjectsSchema = Joi.object({ create: Joi.array() .items(Joi.object({ external_id: Joi.string().required(), name: Joi.string().required(), })) .default([]), delete: Joi.array().items(Joi.string()).default([]), }); router.patch('/:provider/projects', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const { error, value } = updateProjectsSchema.validate(req.body); if (error) { throw new InvalidPayloadError({ reason: error.message }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); // Get provider config const deployment = await service.readByProvider(provider); // Validate deployable projects before any mutation if (value.create.length > 0) { const driver = await service.getDriver(provider); const providerProjects = await driver.listProjects(); const projectsMap = new Map(providerProjects.map((p) => [p.id, p])); const nonDeployable = value.create.filter((p) => !projectsMap.get(p.external_id)?.deployable); if (nonDeployable.length > 0) { const names = nonDeployable .map((p) => projectsMap.get(p.external_id)?.name || p.external_id) .join(', '); throw new InvalidPayloadError({ reason: `Cannot add non-deployable projects: ${names}`, }); } } const updatedProjects = await projectsService.updateSelection(deployment.id, value.create, value.delete); res.locals['payload'] = { data: updatedProjects }; return next(); }), respond); // Dashboard - selected projects with stats router.get('/:provider/dashboard', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); // Get provider config const deployment = await service.readByProvider(provider); // Get selected projects from DB const selectedProjects = await projectsService.readByQuery({ filter: { deployment: { _eq: deployment.id } }, }); if (selectedProjects.length === 0) { res.locals['payload'] = { data: { projects: [] } }; return next(); } // Fetch full details for each selected project (parallel) const driver = await service.getDriver(provider); const projectDetails = await Promise.all(selectedProjects.map(async (p) => { const details = await driver.getProject(p.external_id); return { ...details, id: p.id, external_id: p.external_id, }; })); // Disable cache - dashboard needs fresh data from provider res.locals['cache'] = false; res.locals['payload'] = { data: { projects: projectDetails } }; return next(); }), respond); // Trigger deployment for a project const triggerDeploySchema = Joi.object({ preview: Joi.boolean().default(false), clear_cache: Joi.boolean().default(true), // Default at true (matches Vercel UI behavior) }); router.post('/:provider/projects/:id/deploy', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; const projectId = req.params['id']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const { error, value } = triggerDeploySchema.validate(req.body); if (error) { throw new InvalidPayloadError({ reason: error.message }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); const runsService = new DeploymentRunsService({ accountability: req.accountability, schema: req.schema, }); // Get project from DB const project = await projectsService.readOne(projectId); // Trigger deployment via driver const driver = await service.getDriver(provider); const result = await driver.triggerDeployment(project.external_id, { preview: value.preview, clearCache: value.clear_cache, }); // Store run in DB const runId = await runsService.createOne({ project: projectId, external_id: result.deployment_id, target: value.preview ? 'preview' : 'production', }); const run = await runsService.readOne(runId); res.locals['payload'] = { data: { ...run, status: result.status, url: result.url, }, }; return next(); }), respond); // Update deployment config by provider router.patch('/:provider', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const db = getDatabase(); const item = await transaction(db, async (trx) => { const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, knex: trx, }); const data = {}; if ('credentials' in req.body) data['credentials'] = req.body.credentials; if ('options' in req.body) data['options'] = req.body.options; const primaryKey = await service.updateByProvider(provider, data); try { return await service.readOne(primaryKey, req.sanitizedQuery); } catch (error) { if (isDirectusError(error, ErrorCode.Forbidden)) { return null; } throw error; } }); res.locals['payload'] = { data: item }; return next(); }), respond); // Delete deployment config by provider router.delete('/:provider', asyncHandler(async (req, _res, next) => { const provider = req.params['provider']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); await service.deleteByProvider(provider); return next(); }), respond); // List runs for a project router.get('/:provider/projects/:id/runs', asyncHandler(async (req, res, next) => { // Disable cache - runs status needs to be fresh from provider res.locals['cache'] = false; const provider = req.params['provider']; const projectId = req.params['id']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const projectsService = new DeploymentProjectsService({ accountability: req.accountability, schema: req.schema, }); const runsService = new DeploymentRunsService({ accountability: req.accountability, schema: req.schema, }); // Validate project exists await projectsService.readOne(projectId); // Get paginated runs from DB (default limit: 10) const query = { ...req.sanitizedQuery, filter: { project: { _eq: projectId } }, sort: ['-date_created'], limit: req.sanitizedQuery.limit ?? 10, fields: ['*', 'user_created.first_name', 'user_created.last_name', 'user_created.email'], }; const runs = await runsService.readByQuery(query); // Get pagination meta const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, }); const meta = await metaService.getMetaForQuery('directus_deployment_runs', query); // Fetch status for each run from provider const driver = await service.getDriver(provider); const runsWithStatus = await Promise.all(runs.map(async (run) => { const details = await driver.getDeployment(run.external_id); return { ...run, ...details, id: run.id, external_id: run.external_id, }; })); res.locals['payload'] = { data: runsWithStatus, meta }; return next(); }), respond); // Get single run details const runDetailsQuerySchema = Joi.object({ since: Joi.date().iso().optional(), _t: Joi.number().optional(), // Cache-buster parameter for polling }); router.get('/:provider/runs/:id', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; const runId = req.params['id']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const { error, value } = runDetailsQuerySchema.validate(req.query); if (error) { throw new InvalidPayloadError({ reason: error.message }); } const sinceDate = value.since; const runsService = new DeploymentRunsService({ accountability: req.accountability, schema: req.schema, }); const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const run = await runsService.readOne(runId); const driver = await service.getDriver(provider); const [details, logs] = await Promise.all([ driver.getDeployment(run.external_id), driver.getDeploymentLogs(run.external_id, sinceDate ? { since: sinceDate } : undefined), ]); res.locals['cache'] = false; res.locals['payload'] = { data: { ...run, ...details, id: run.id, external_id: run.external_id, logs, }, }; return next(); }), respond); // Cancel a deployment router.post('/:provider/runs/:id/cancel', asyncHandler(async (req, res, next) => { const provider = req.params['provider']; const runId = req.params['id']; if (!validateProvider(provider)) { throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` }); } const runsService = new DeploymentRunsService({ accountability: req.accountability, schema: req.schema, }); const service = new DeploymentService({ accountability: req.accountability, schema: req.schema, }); const run = await runsService.readOne(runId); const driver = await service.getDriver(provider); await driver.cancelDeployment(run.external_id); // Fetch updated status const details = await driver.getDeployment(run.external_id); res.locals['payload'] = { data: { ...run, ...details, id: run.id, external_id: run.external_id, }, }; return next(); }), respond); export default router;