UNPKG

@directus/api

Version:

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

345 lines (343 loc) 13.5 kB
import { OAS_REQUIRED_SCHEMAS } from "../constants.js"; import database_default from "../database/index.js"; import { reduceSchema } from "../utils/reduce-schema.js"; import { fetchPolicies } from "../permissions/lib/fetch-policies.js"; import { fetchAllowedFieldMap } from "../permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js"; import { fetchPermissions } from "../permissions/lib/fetch-permissions.js"; import { GraphQLService } from "./graphql/index.js"; import { useEnv } from "@directus/env"; import { getRelation, getRelationType } from "@directus/utils"; import { cloneDeep, mergeWith } from "lodash-es"; import hash from "object-hash"; import { isSystemCollection } from "@directus/system-data"; import formatTitle from "@directus/format-title"; import { spec } from "@directus/specs"; //#region src/services/specifications.ts const env = useEnv(); var SpecificationService = class { accountability; knex; schema; oas; graphql; constructor(options) { this.accountability = options.accountability || null; this.knex = options.knex || database_default(); this.schema = options.schema; this.oas = new OASSpecsService(options); this.graphql = new GraphQLSpecsService(options); } }; var OASSpecsService = class { accountability; knex; schema; constructor(options) { this.accountability = options.accountability || null; this.knex = options.knex || database_default(); this.schema = options.schema; } async generate(host) { let schemaForSpec = this.schema; let permissions = []; if (this.accountability && this.accountability.admin !== true) { const allowedFields = await fetchAllowedFieldMap({ accountability: this.accountability, action: "read" }, { schema: this.schema, knex: this.knex }); schemaForSpec = reduceSchema(this.schema, allowedFields); permissions = await fetchPermissions({ policies: await fetchPolicies(this.accountability, { schema: this.schema, knex: this.knex }), accountability: this.accountability }, { schema: this.schema, knex: this.knex }); } const tags = await this.generateTags(schemaForSpec); const paths = await this.generatePaths(schemaForSpec, permissions, tags); const components = await this.generateComponents(schemaForSpec, tags); const url = env["PUBLIC_URL"] === "/" && host ? host : env["PUBLIC_URL"]; const spec$1 = { openapi: "3.0.1", info: { title: "Dynamic API Specification", description: "This is a dynamically generated API specification for all endpoints existing on the current project.", version: hash({ now: (/* @__PURE__ */ new Date()).toISOString(), user: this.accountability?.user }) }, servers: [{ url, description: "Your current Directus instance." }], paths }; if (tags) spec$1.tags = tags; if (components) spec$1.components = components; return spec$1; } async generateTags(schema) { const systemTags = cloneDeep(spec.tags); const collections = Object.values(schema.collections); const tags = []; for (const systemTag of systemTags) { if (systemTag["x-authentication"] === "admin" && !this.accountability?.admin) continue; if (systemTag["x-authentication"] === "user" && !this.accountability?.user) continue; if (!systemTag["x-collection"]) tags.push(systemTag); } for (const collection of collections) if (isSystemCollection(collection.collection)) { for (const tag of spec.tags) if (tag["x-collection"] === collection.collection) { tags.push(tag); break; } } else { const tag = { name: "Items" + formatTitle(collection.collection).replace(/ /g, ""), "x-collection": collection.collection }; if (collection.note) tag.description = collection.note; tags.push(tag); } return tags.filter((tag) => tag.name !== "Items"); } async generatePaths(schema, permissions, tags) { const paths = {}; if (!tags) return paths; for (const tag of tags) if ("x-collection" in tag === false || isSystemCollection(tag["x-collection"])) { for (const [path, pathItem] of Object.entries(spec.paths)) for (const [method, operation] of Object.entries(pathItem)) if (operation.tags?.includes(tag.name)) { if (!paths[path]) paths[path] = {}; if (this.accountability?.admin === true || "x-collection" in tag === false || !!permissions.find((permission) => permission.collection === tag["x-collection"] && permission.action === this.getActionForMethod(method))) if ("parameters" in pathItem) paths[path][method] = { ...operation, parameters: [...pathItem.parameters ?? [], ...operation?.parameters ?? []] }; else paths[path][method] = operation; } } else { const listBase = cloneDeep(spec.paths["/items/{collection}"]); const detailBase = cloneDeep(spec.paths["/items/{collection}/{id}"]); const collection = tag["x-collection"]; for (const method of [ "post", "get", "patch", "delete" ]) if (this.accountability?.admin === true || !!permissions.find((permission) => permission.collection === collection && permission.action === this.getActionForMethod(method))) { if (!paths[`/items/${collection}`]) paths[`/items/${collection}`] = {}; if (!paths[`/items/${collection}/{id}`]) paths[`/items/${collection}/{id}`] = {}; if (listBase?.[method]) paths[`/items/${collection}`][method] = mergeWith(cloneDeep(listBase[method]), { description: listBase[method].description.replace("item", collection + " item"), tags: [tag.name], parameters: "parameters" in listBase ? this.filterCollectionFromParams(listBase.parameters) : [], operationId: `${this.getActionForMethod(method)}${tag.name}`, requestBody: ["get", "delete"].includes(method) ? void 0 : { content: { "application/json": { schema: { oneOf: [{ type: "array", items: { $ref: `#/components/schemas/${tag.name}` } }, { $ref: `#/components/schemas/${tag.name}` }] } } } }, responses: { "200": { description: "Successful request", content: method === "delete" ? void 0 : { "application/json": { schema: { properties: { data: schema.collections[collection]?.singleton ? { $ref: `#/components/schemas/${tag.name}` } : { type: "array", items: { $ref: `#/components/schemas/${tag.name}` } } } } } } } } }, (obj, src) => { if (Array.isArray(obj)) return obj.concat(src); }); if (detailBase?.[method]) paths[`/items/${collection}/{id}`][method] = mergeWith(cloneDeep(detailBase[method]), { description: detailBase[method].description.replace("item", collection + " item"), tags: [tag.name], operationId: `${this.getActionForMethod(method)}Single${tag.name}`, parameters: "parameters" in detailBase ? this.filterCollectionFromParams(detailBase.parameters) : [], requestBody: ["get", "delete"].includes(method) ? void 0 : { content: { "application/json": { schema: { $ref: `#/components/schemas/${tag.name}` } } } }, responses: { "200": { content: method === "delete" ? void 0 : { "application/json": { schema: { properties: { data: { $ref: `#/components/schemas/${tag.name}` } } } } } } } }, (obj, src) => { if (Array.isArray(obj)) return obj.concat(src); }); } } return paths; } async generateComponents(schema, tags) { if (!tags) return; let components = cloneDeep(spec.components); if (!components) components = {}; components.schemas = {}; const tagSchemas = tags.reduce((schemas, tag) => [...schemas, ...tag["x-schemas"] ? tag["x-schemas"] : []], []); const requiredSchemas = [...OAS_REQUIRED_SCHEMAS, ...tagSchemas]; for (const [name, schema$1] of Object.entries(spec.components?.schemas ?? {})) if (requiredSchemas.includes(name)) { const collection = spec.tags?.find((tag) => tag.name === name)?.["x-collection"]; components.schemas[name] = { ...cloneDeep(schema$1), ...collection && { "x-collection": collection } }; } const collections = Object.values(schema.collections); for (const collection of collections) { const tag = tags.find((tag$1) => tag$1["x-collection"] === collection.collection); if (!tag) continue; const isSystem = isSystemCollection(collection.collection); const fieldsInCollection = Object.values(collection.fields); if (isSystem) { const schemaComponent = cloneDeep(spec.components.schemas[tag.name]); schemaComponent.properties = {}; schemaComponent["x-collection"] = collection.collection; for (const field of fieldsInCollection) schemaComponent.properties[field.field] = cloneDeep(spec.components.schemas[tag.name].properties[field.field]) || this.generateField(schema, collection.collection, field, tags); components.schemas[tag.name] = schemaComponent; } else { const schemaComponent = { type: "object", properties: {}, "x-collection": collection.collection }; const requiredFields = []; for (const field of fieldsInCollection) { const fieldSchema = this.generateField(schema, collection.collection, field, tags); schemaComponent.properties[field.field] = fieldSchema; if (field.nullable === false && field.defaultValue === null && field.generated === false) requiredFields.push(field.field); } if (requiredFields.length > 0) schemaComponent.required = requiredFields; components.schemas[tag.name] = schemaComponent; } } return components; } filterCollectionFromParams(parameters) { return parameters.filter((param) => param?.$ref !== "#/components/parameters/Collection"); } getActionForMethod(method) { switch (method) { case "post": return "create"; case "patch": return "update"; case "delete": return "delete"; case "get": default: return "read"; } } generateField(schema, collection, field, tags) { let propertyObject = {}; propertyObject.nullable = field.nullable; if (field.note) propertyObject.description = field.note; const relation = getRelation(schema.relations, collection, field.field); if (!relation) propertyObject = { ...propertyObject, ...this.fieldTypes[field.type] }; else { const relationType = getRelationType({ relation, field: field.field, collection, useA2O: true }); if (relationType === "m2o") { const relatedTag = tags.find((tag) => tag["x-collection"] === relation.related_collection); if (!relatedTag || !relation.related_collection || relation.related_collection in schema.collections === false) return propertyObject; const relatedCollection = schema.collections[relation.related_collection]; const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]; propertyObject.oneOf = [{ ...this.fieldTypes[relatedPrimaryKeyField.type] }, { $ref: `#/components/schemas/${relatedTag.name}` }]; } else if (relationType === "o2m") { const relatedTag = tags.find((tag) => tag["x-collection"] === relation.collection); if (!relatedTag || !relation.related_collection || relation.collection in schema.collections === false) return propertyObject; const relatedCollection = schema.collections[relation.collection]; const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]; if (!relatedTag || !relatedPrimaryKeyField) return propertyObject; propertyObject.type = "array"; propertyObject.items = { oneOf: [{ ...this.fieldTypes[relatedPrimaryKeyField.type] }, { $ref: `#/components/schemas/${relatedTag.name}` }] }; } else if (relationType === "a2o") { const relatedTags = tags.filter((tag) => relation.meta.one_allowed_collections.includes(tag["x-collection"])); propertyObject.type = "array"; propertyObject.items = { oneOf: [{ type: "string" }, ...relatedTags.map((tag) => ({ $ref: `#/components/schemas/${tag.name}` }))] }; } } return propertyObject; } fieldTypes = { alias: { type: "string" }, bigInteger: { type: "integer", format: "int64" }, binary: { type: "string", format: "binary" }, boolean: { type: "boolean" }, csv: { type: "array", items: { type: "string" } }, date: { type: "string", format: "date" }, dateTime: { type: "string", format: "date-time" }, decimal: { type: "number" }, float: { type: "number", format: "float" }, hash: { type: "string" }, integer: { type: "integer" }, json: {}, string: { type: "string" }, text: { type: "string" }, time: { type: "string", format: "time" }, timestamp: { type: "string", format: "timestamp" }, unknown: {}, uuid: { type: "string", format: "uuid" }, geometry: { type: "object" }, "geometry.Point": { type: "object" }, "geometry.LineString": { type: "object" }, "geometry.Polygon": { type: "object" }, "geometry.MultiPoint": { type: "object" }, "geometry.MultiLineString": { type: "object" }, "geometry.MultiPolygon": { type: "object" } }; }; var GraphQLSpecsService = class { accountability; knex; schema; items; system; constructor(options) { this.accountability = options.accountability || null; this.knex = options.knex || database_default(); this.schema = options.schema; this.items = new GraphQLService({ ...options, scope: "items" }); this.system = new GraphQLService({ ...options, scope: "system" }); } async generate(scope) { if (scope === "items") return this.items.getSchema("sdl"); if (scope === "system") return this.system.getSchema("sdl"); return null; } }; //#endregion export { SpecificationService };