Merge branch 'main' into fix/i18n

This commit is contained in:
rijkvanzanten
2020-10-12 14:24:41 -04:00
137 changed files with 6540 additions and 2675 deletions

View File

@@ -1,4 +1,4 @@
import { AST, NestedCollectionAST } from '../types/ast';
import { AST, NestedCollectionNode, FieldNode, M2ONode, O2MNode } from '../types/ast';
import { clone, cloneDeep, uniq, pick } from 'lodash';
import database from './index';
import SchemaInspector from 'knex-schema-inspector';
@@ -13,75 +13,107 @@ type RunASTOptions = {
child?: boolean;
};
export default async function runAST(originalAST: AST, options?: RunASTOptions): Promise<null | Item | Item[]> {
export default async function runAST(
originalAST: AST | NestedCollectionNode,
options?: RunASTOptions
): Promise<null | Item | Item[]> {
const ast = cloneDeep(originalAST);
const query = options?.query || ast.query;
const knex = options?.knex || database;
// Retrieve the database columns to select in the current AST
const { columnsToSelect, primaryKeyField, nestedCollectionASTs } = await parseCurrentLevel(ast, knex);
if (ast.type === 'm2a') {
const results: { [collection: string]: null | Item | Item[] } = {};
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
const dbQuery = await getDBQuery(knex, ast.name, columnsToSelect, query, primaryKeyField);
const rawItems: Item | Item[] = await dbQuery;
if (!rawItems) return null;
// Run the items through the special transforms
const payloadService = new PayloadService(ast.name, { knex });
let items = await payloadService.processValues('read', rawItems);
if (!items || items.length === 0) return items;
// Apply the `_in` filters to the nested collection batches
const nestedASTs = applyParentFilters(nestedCollectionASTs, items);
for (const nestedAST of nestedASTs) {
let tempLimit: number | null = null;
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
// all nested items for all parent items at once. Because of this, we can't limit that query
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
// that there's _n_ items, which are then divided on the parent items. (no good)
if (isO2M(nestedAST) && typeof nestedAST.query.limit === 'number') {
tempLimit = nestedAST.query.limit;
nestedAST.query.limit = -1;
for (const collection of ast.names) {
results[collection] = await run(
collection,
ast.children[collection],
ast.query[collection]
);
}
let nestedItems = await runAST(nestedAST, { knex, child: true });
return results;
} else {
return await run(ast.name, ast.children, options?.query || ast.query);
}
if (nestedItems) {
// Merge all fetched nested records with the parent items
items = mergeWithParentItems(nestedItems, items, nestedAST, tempLimit);
async function run(
collection: string,
children: (NestedCollectionNode | FieldNode)[],
query: Query
) {
// Retrieve the database columns to select in the current AST
const { columnsToSelect, primaryKeyField, nestedCollectionNodes } = await parseCurrentLevel(
collection,
children,
knex
);
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
const dbQuery = await getDBQuery(knex, collection, columnsToSelect, query, primaryKeyField);
const rawItems: Item | Item[] = await dbQuery;
if (!rawItems) return null;
// Run the items through the special transforms
const payloadService = new PayloadService(collection, { knex });
let items: null | Item | Item[] = await payloadService.processValues('read', rawItems);
if (!items || items.length === 0) return items;
// Apply the `_in` filters to the nested collection batches
const nestedNodes = applyParentFilters(nestedCollectionNodes, items);
for (const nestedNode of nestedNodes) {
let tempLimit: number | null = null;
// Nested o2m-items are fetched from the db in a single query. This means that we're fetching
// all nested items for all parent items at once. Because of this, we can't limit that query
// to the "standard" item limit. Instead of _n_ nested items per parent item, it would mean
// that there's _n_ items, which are then divided on the parent items. (no good)
if (nestedNode.type === 'o2m' && typeof nestedNode.query.limit === 'number') {
tempLimit = nestedNode.query.limit;
nestedNode.query.limit = -1;
}
let nestedItems = await runAST(nestedNode, { knex, child: true });
if (nestedItems) {
// Merge all fetched nested records with the parent items
items = mergeWithParentItems(nestedItems, items, nestedNode, tempLimit);
}
}
}
// During the fetching of data, we have to inject a couple of required fields for the child nesting
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
// and nesting is done, we parse through the output structure, and filter out all non-requested
// fields
if (options?.child !== true) {
items = removeTemporaryFields(items, originalAST);
}
// During the fetching of data, we have to inject a couple of required fields for the child nesting
// to work (primary / foreign keys) even if they're not explicitly requested. After all fetching
// and nesting is done, we parse through the output structure, and filter out all non-requested
// fields
if (options?.child !== true) {
items = removeTemporaryFields(items, originalAST, primaryKeyField);
}
return items;
return items;
}
}
async function parseCurrentLevel(ast: AST, knex: Knex) {
async function parseCurrentLevel(
collection: string,
children: (NestedCollectionNode | FieldNode)[],
knex: Knex
) {
const schemaInspector = SchemaInspector(knex);
const primaryKeyField = await schemaInspector.primary(ast.name);
const primaryKeyField = await schemaInspector.primary(collection);
const columnsInCollection = (await schemaInspector.columns(ast.name)).map(
const columnsInCollection = (await schemaInspector.columns(collection)).map(
({ column }) => column
);
const columnsToSelect: string[] = [];
const nestedCollectionASTs: NestedCollectionAST[] = [];
const nestedCollectionNodes: NestedCollectionNode[] = [];
for (const child of ast.children) {
for (const child of children) {
if (child.type === 'field') {
if (columnsInCollection.includes(child.name) || child.name === '*') {
columnsToSelect.push(child.name);
@@ -92,13 +124,16 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
if (!child.relation) continue;
const m2o = isM2O(child);
if (m2o) {
if (child.type === 'm2o') {
columnsToSelect.push(child.relation.many_field);
}
nestedCollectionASTs.push(child);
if (child.type === 'm2a') {
columnsToSelect.push(child.relation.many_field);
columnsToSelect.push(child.relation.one_collection_field!);
}
nestedCollectionNodes.push(child);
}
/** Always fetch primary key in case there's a nested relation that needs it */
@@ -106,10 +141,16 @@ async function parseCurrentLevel(ast: AST, knex: Knex) {
columnsToSelect.push(primaryKeyField);
}
return { columnsToSelect, nestedCollectionASTs, primaryKeyField };
return { columnsToSelect, nestedCollectionNodes, primaryKeyField };
}
async function getDBQuery(knex: Knex, table: string, columns: string[], query: Query, primaryKeyField: string): Promise<QueryBuilder> {
async function getDBQuery(
knex: Knex,
table: string,
columns: string[],
query: Query,
primaryKeyField: string
): Promise<QueryBuilder> {
let dbQuery = knex.select(columns.map((column) => `${table}.${column}`)).from(table);
const queryCopy = clone(query);
@@ -127,115 +168,224 @@ async function getDBQuery(knex: Knex, table: string, columns: string[], query: Q
return dbQuery;
}
function applyParentFilters(nestedCollectionASTs: NestedCollectionAST[], parentItem: Item | Item[]) {
function applyParentFilters(
nestedCollectionNodes: NestedCollectionNode[],
parentItem: Item | Item[]
) {
const parentItems = Array.isArray(parentItem) ? parentItem : [parentItem];
for (const nestedAST of nestedCollectionASTs) {
if (!nestedAST.relation) continue;
for (const nestedNode of nestedCollectionNodes) {
if (!nestedNode.relation) continue;
if (isM2O(nestedAST)) {
nestedAST.query = {
...nestedAST.query,
if (nestedNode.type === 'm2o') {
nestedNode.query = {
...nestedNode.query,
filter: {
...(nestedAST.query.filter || {}),
[nestedAST.relation.one_primary]: {
_in: uniq(parentItems.map((res) => res[nestedAST.relation.many_field])).filter(
(id) => id
),
}
}
}
} else {
const relatedM2OisFetched = !!nestedAST.children.find((child) => {
return child.type === 'field' && child.name === nestedAST.relation.many_field
...(nestedNode.query.filter || {}),
[nestedNode.relation.one_primary!]: {
_in: uniq(
parentItems.map((res) => res[nestedNode.relation.many_field])
).filter((id) => id),
},
},
};
} else if (nestedNode.type === 'o2m') {
const relatedM2OisFetched = !!nestedNode.children.find((child) => {
return child.type === 'field' && child.name === nestedNode.relation.many_field;
});
if (relatedM2OisFetched === false) {
nestedAST.children.push({ type: 'field', name: nestedAST.relation.many_field });
nestedNode.children.push({ type: 'field', name: nestedNode.relation.many_field });
}
nestedAST.query = {
...nestedAST.query,
nestedNode.query = {
...nestedNode.query,
filter: {
...(nestedAST.query.filter || {}),
[nestedAST.relation.many_field]: {
_in: uniq(parentItems.map((res) => res[nestedAST.parentKey])).filter((id) => id),
}
}
...(nestedNode.query.filter || {}),
[nestedNode.relation.many_field]: {
_in: uniq(parentItems.map((res) => res[nestedNode.parentKey])).filter(
(id) => id
),
},
},
};
} else if (nestedNode.type === 'm2a') {
const keysPerCollection: { [collection: string]: (string | number)[] } = {};
for (const parentItem of parentItems) {
const collection = parentItem[nestedNode.relation.one_collection_field!];
if (!keysPerCollection[collection]) keysPerCollection[collection] = [];
keysPerCollection[collection].push(parentItem[nestedNode.relation.many_field]);
}
for (const relatedCollection of nestedNode.names) {
nestedNode.query[relatedCollection] = {
...nestedNode.query[relatedCollection],
filter: {
_and: [
nestedNode.query[relatedCollection].filter,
{
[nestedNode.relatedKey[relatedCollection]]: {
_in: uniq(keysPerCollection[relatedCollection]),
},
},
].filter((f) => f),
},
};
}
}
}
return nestedCollectionASTs;
return nestedCollectionNodes;
}
function mergeWithParentItems(nestedItem: Item | Item[], parentItem: Item | Item[], nestedAST: NestedCollectionAST, o2mLimit?: number | null) {
function mergeWithParentItems(
nestedItem: Item | Item[],
parentItem: Item | Item[],
nestedNode: NestedCollectionNode,
o2mLimit?: number | null
) {
const nestedItems = Array.isArray(nestedItem) ? nestedItem : [nestedItem];
const parentItems = clone(Array.isArray(parentItem) ? parentItem : [parentItem]);
if (isM2O(nestedAST)) {
if (nestedNode.type === 'm2o') {
for (const parentItem of parentItems) {
const itemChild = nestedItems.find((nestedItem) => {
return nestedItem[nestedAST.relation.one_primary] === parentItem[nestedAST.fieldKey];
return (
nestedItem[nestedNode.relation.one_primary!] === parentItem[nestedNode.fieldKey]
);
});
parentItem[nestedAST.fieldKey] = itemChild || null;
parentItem[nestedNode.fieldKey] = itemChild || null;
}
} else {
} else if (nestedNode.type === 'o2m') {
for (const parentItem of parentItems) {
let itemChildren = nestedItems.filter((nestedItem) => {
if (nestedItem === null) return false;
if (Array.isArray(nestedItem[nestedAST.relation.many_field])) return true;
if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true;
return (
nestedItem[nestedAST.relation.many_field] === parentItem[nestedAST.relation.one_primary] ||
nestedItem[nestedAST.relation.many_field]?.[nestedAST.relation.many_primary] === parentItem[nestedAST.relation.one_primary]
nestedItem[nestedNode.relation.many_field] ===
parentItem[nestedNode.relation.one_primary!] ||
nestedItem[nestedNode.relation.many_field]?.[
nestedNode.relation.many_primary
] === parentItem[nestedNode.relation.one_primary!]
);
});
// We re-apply the requested limit here. This forces the _n_ nested items per parent concept
if (o2mLimit !== null) {
itemChildren = itemChildren.slice(0, o2mLimit);
nestedAST.query.limit = o2mLimit;
nestedNode.query.limit = o2mLimit;
}
parentItem[nestedAST.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
parentItem[nestedNode.fieldKey] = itemChildren.length > 0 ? itemChildren : null;
}
} else if (nestedNode.type === 'm2a') {
for (const parentItem of parentItems) {
const relatedCollection = parentItem[nestedNode.relation.one_collection_field!];
const itemChild = (nestedItem as Record<string, any[]>)[relatedCollection].find(
(nestedItem) => {
return (
nestedItem[nestedNode.relatedKey[relatedCollection]] ===
parentItem[nestedNode.fieldKey]
);
}
);
parentItem[nestedNode.fieldKey] = itemChild || null;
}
}
return Array.isArray(parentItem) ? parentItems : parentItems[0];
}
function removeTemporaryFields(rawItem: Item | Item[], ast: AST | NestedCollectionAST): Item | Item[] {
const rawItems: Item[] = Array.isArray(rawItem) ? rawItem : [rawItem];
function removeTemporaryFields(
rawItem: Item | Item[],
ast: AST | NestedCollectionNode,
primaryKeyField: string,
parentItem?: Item
): null | Item | Item[] {
const rawItems = cloneDeep(Array.isArray(rawItem) ? rawItem : [rawItem]);
const items: Item[] = [];
const fields = ast.children.filter((child) => child.type === 'field').map((child) => child.name);
const nestedCollections = ast.children.filter((child) => child.type === 'collection') as NestedCollectionAST[];
if (ast.type === 'm2a') {
const fields: Record<string, string[]> = {};
const nestedCollectionNodes: Record<string, NestedCollectionNode[]> = {};
for (const rawItem of rawItems) {
if (rawItem === null) return rawItem;
const item = fields.includes('*') ? rawItem : pick(rawItem, fields);
for (const relatedCollection of ast.names) {
if (!fields[relatedCollection]) fields[relatedCollection] = [];
if (!nestedCollectionNodes[relatedCollection])
nestedCollectionNodes[relatedCollection] = [];
for (const nestedCollection of nestedCollections) {
if (item[nestedCollection.fieldKey] !== null) {
item[nestedCollection.fieldKey] = removeTemporaryFields(rawItem[nestedCollection.fieldKey], nestedCollection);
for (const child of ast.children[relatedCollection]) {
if (child.type === 'field') {
fields[relatedCollection].push(child.name);
} else {
fields[relatedCollection].push(child.fieldKey);
nestedCollectionNodes[relatedCollection].push(child);
}
}
}
items.push(item);
for (const rawItem of rawItems) {
const relatedCollection: string = parentItem?.[ast.relation.one_collection_field!];
if (rawItem === null || rawItem === undefined) return rawItem;
let item = rawItem;
for (const nestedNode of nestedCollectionNodes[relatedCollection]) {
item[nestedNode.fieldKey] = removeTemporaryFields(
item[nestedNode.fieldKey],
nestedNode,
nestedNode.relation.many_primary,
item
);
}
item =
fields[relatedCollection].length > 0
? pick(rawItem, fields[relatedCollection])
: rawItem[primaryKeyField];
items.push(item);
}
} else {
const fields: string[] = [];
const nestedCollectionNodes: NestedCollectionNode[] = [];
for (const child of ast.children) {
if (child.type === 'field') {
fields.push(child.name);
} else {
fields.push(child.fieldKey);
nestedCollectionNodes.push(child);
}
}
for (const rawItem of rawItems) {
if (rawItem === null || rawItem === undefined) return rawItem;
let item = rawItem;
for (const nestedNode of nestedCollectionNodes) {
item[nestedNode.fieldKey] = removeTemporaryFields(
item[nestedNode.fieldKey],
nestedNode,
nestedNode.type === 'm2o'
? nestedNode.relation.many_primary
: nestedNode.relation.one_primary!,
item
);
}
item = fields.length > 0 ? pick(rawItem, fields) : rawItem[primaryKeyField];
items.push(item);
}
}
return Array.isArray(rawItem) ? items : items[0];
}
function isM2O(child: NestedCollectionAST) {
return (
child.relation.one_collection === child.name && child.relation.many_field === child.fieldKey
);
}
function isO2M(child: NestedCollectionAST) {
return isM2O(child) === false;
}

View File

@@ -35,6 +35,13 @@ columns:
type: timestamp
nullable: false
default: '$now'
modified_by:
type: uuid
references:
table: directus_users
column: id
modified_on:
type: timestamp
charset:
type: string
length: 50

View File

@@ -21,7 +21,6 @@ columns:
one_collection:
type: string
length: 64
nullable: false
references:
table: directus_collections
column: collection
@@ -31,7 +30,11 @@ columns:
one_primary:
type: string
length: 64
nullable: false
one_collection_field:
type: string
length: 64
one_allowed_collections:
type: text
junction_field:
type: string
length: 64

View File

@@ -57,7 +57,7 @@ data:
many_primary: id
one_collection: directus_collections
one_field: fields
one_primary: id
one_primary: collection
- many_collection: directus_activity
many_field: user
many_primary: id

View File

@@ -77,7 +77,7 @@ fields:
locked: true
options:
template: '{{ name }}'
createItemText: Add Module
addLabel: Add New Module...
fields:
- name: Icon
field: icon
@@ -112,7 +112,7 @@ fields:
locked: true
options:
template: '{{ group_name }}'
createItemText: Add Group
addLabel: Add New Group...
fields:
- name: Group Name
field: group_name
@@ -123,7 +123,9 @@ fields:
options:
iconRight: title
placeholder: Label this group...
- name: Accordion
schema:
is_nullable: false
- name: Type
field: accordion
type: string
schema:
@@ -145,7 +147,7 @@ fields:
meta:
interface: repeater
options:
createItemText: Add Collection
addLabel: Add New Collection...
template: '{{ collection }}'
fields:
- name: Collection
@@ -154,6 +156,8 @@ fields:
meta:
interface: collection
width: full
schema:
is_nullable: false
special: json
sort: 10
width: full

View File

@@ -33,6 +33,7 @@ fields:
special: json
sort: 3
width: full
display: tags
- collection: directus_files
field: location
interface: text-input
@@ -91,3 +92,23 @@ fields:
- collection: directus_files
field: filesize
display: filesize
- collection: directus_files
field: modified_by
interface: user
locked: true
special: user-updated
width: half
display: user
- collection: directus_files
field: modified_on
interface: dateTime
locked: true
special: date-updated
width: half
display: datetime
- collection: directus_files
field: created_on
display: datetime
- collection: directus_files
field: created_by
display: user

View File

@@ -0,0 +1,7 @@
table: directus_relations
fields:
- collection: directus_relations
field: one_allowed_collections
locked: true
special: csv

View File

@@ -3,8 +3,8 @@ import {
Accountability,
AbstractServiceOptions,
AST,
NestedCollectionAST,
FieldAST,
NestedCollectionNode,
FieldNode,
Query,
Permission,
PermissionsAction,
@@ -74,30 +74,35 @@ export class AuthorizationService {
* Traverses the AST and returns an array of all collections that are being fetched
*/
function getCollectionsFromAST(
ast: AST | NestedCollectionAST
ast: AST | NestedCollectionNode
): { collection: string; field: string }[] {
const collections = [];
if (ast.type === 'collection') {
if (ast.type === 'm2a') {
collections.push(
...ast.names.map((name) => ({ collection: name, field: ast.fieldKey }))
);
/** @TODO add nestedNode */
} else {
collections.push({
collection: ast.name,
field: (ast as NestedCollectionAST).fieldKey
? (ast as NestedCollectionAST).fieldKey
: null,
field: ast.type === 'root' ? null : ast.fieldKey,
});
}
for (const subAST of ast.children) {
if (subAST.type === 'collection') {
collections.push(...getCollectionsFromAST(subAST));
for (const nestedNode of ast.children) {
if (nestedNode.type !== 'field') {
collections.push(...getCollectionsFromAST(nestedNode));
}
}
}
return collections as { collection: string; field: string }[];
}
function validateFields(ast: AST | NestedCollectionAST) {
if (ast.type === 'collection') {
function validateFields(ast: AST | NestedCollectionNode | FieldNode) {
if (ast.type !== 'field' && ast.type !== 'm2a') {
/** @TODO remove m2a check */
const collection = ast.name;
// We check the availability of the permissions in the step before this is run
@@ -107,15 +112,15 @@ export class AuthorizationService {
const allowedFields = permissions.fields?.split(',') || [];
for (const childAST of ast.children) {
if (childAST.type === 'collection') {
validateFields(childAST);
for (const childNode of ast.children) {
if (childNode.type !== 'field') {
validateFields(childNode);
continue;
}
if (allowedFields.includes('*')) continue;
const fieldKey = childAST.name;
const fieldKey = childNode.name;
if (allowedFields.includes(fieldKey) === false) {
throw new ForbiddenException(
@@ -127,10 +132,11 @@ export class AuthorizationService {
}
function applyFilters(
ast: AST | NestedCollectionAST | FieldAST,
ast: AST | NestedCollectionNode | FieldNode,
accountability: Accountability | null
): AST | NestedCollectionAST | FieldAST {
if (ast.type === 'collection') {
): AST | NestedCollectionNode | FieldNode {
if (ast.type !== 'field' && ast.type !== 'm2a') {
/** @TODO remove m2a check */
const collection = ast.name;
// We check the availability of the permissions in the step before this is run
@@ -164,8 +170,8 @@ export class AuthorizationService {
}
ast.children = ast.children.map((child) => applyFilters(child, accountability)) as (
| NestedCollectionAST
| FieldAST
| NestedCollectionNode
| FieldNode
)[];
}
@@ -198,7 +204,17 @@ export class AuthorizationService {
let permission: Permission | undefined;
if (this.accountability?.admin === true) {
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
permission = {
id: 0,
role: this.accountability?.role,
collection,
action,
permissions: {},
validation: {},
limit: null,
fields: '*',
presets: {},
};
} else {
permission = await this.knex
.select<Permission>('*')
@@ -238,10 +254,23 @@ export class AuthorizationService {
let requiredColumns: string[] = [];
for (const column of columns) {
const field = await this.knex.select<{ special: string }>('special').from('directus_fields').where({ collection, field: column.name }).first();
const field = await this.knex
.select<{ special: string }>('special')
.from('directus_fields')
.where({ collection, field: column.name })
.first();
const specials = (field?.special || '').split(',');
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name));
const isRequired = column.is_nullable === false && column.has_auto_increment === false && column.default_value === null && hasGenerateSpecial === false;
const hasGenerateSpecial = [
'uuid',
'date-created',
'role-created',
'user-created',
].some((name) => specials.includes(name));
const isRequired =
column.is_nullable === false &&
column.has_auto_increment === false &&
column.default_value === null &&
hasGenerateSpecial === false;
if (isRequired) {
requiredColumns.push(column.name);
@@ -250,23 +279,20 @@ export class AuthorizationService {
if (requiredColumns.length > 0) {
permission.validation = {
_and: [
permission.validation,
{}
]
}
_and: [permission.validation, {}],
};
if (action === 'create') {
for (const name of requiredColumns) {
permission.validation._and[1][name] = {
_required: true
}
_required: true,
};
}
} else {
for (const name of requiredColumns) {
permission.validation._and[1][name] = {
_nnull: true
}
_nnull: true,
};
}
}
}
@@ -282,7 +308,10 @@ export class AuthorizationService {
}
}
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
validateJoi(
validation: Record<string, any>,
payloads: Partial<Record<string, any>>[]
): FailedValidationException[] {
const errors: FailedValidationException[] = [];
/**
@@ -291,13 +320,21 @@ export class AuthorizationService {
if (Object.keys(validation)[0] === '_and') {
const subValidation = Object.values(validation)[0];
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads))).filter((err?: FailedValidationException) => err);
const nestedErrors = flatten<FailedValidationException>(
subValidation.map((subObj: Record<string, any>) =>
this.validateJoi(subObj, payloads)
)
).filter((err?: FailedValidationException) => err);
errors.push(...nestedErrors);
}
if (Object.keys(validation)[0] === '_or') {
const subValidation = Object.values(validation)[0];
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)));
const nestedErrors = flatten<FailedValidationException>(
subValidation.map((subObj: Record<string, any>) =>
this.validateJoi(subObj, payloads)
)
);
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
if (allErrored) {
@@ -311,7 +348,9 @@ export class AuthorizationService {
const { error } = schema.validate(payload, { abortEarly: false });
if (error) {
errors.push(...error.details.map((details) => new FailedValidationException(details)));
errors.push(
...error.details.map((details) => new FailedValidationException(details))
);
}
}

View File

@@ -132,9 +132,9 @@ export class CollectionsService {
const tablesInDatabase = await schemaInspector.tableInfo();
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
const meta = await collectionItemsService.readByQuery({
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: collectionKeys } },
}) as Collection['meta'][];
})) as Collection['meta'][];
const collections: Collection[] = [];
@@ -170,9 +170,9 @@ export class CollectionsService {
}
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
const meta = await collectionItemsService.readByQuery({
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: tablesToFetchInfoFor } },
}) as Collection['meta'][];
})) as Collection['meta'][];
const collections: Collection[] = [];
@@ -287,11 +287,14 @@ export class CollectionsService {
for (const relation of relations) {
const isM2O = relation.many_collection === collection;
/** @TODO M2A — Handle m2a case here */
if (isM2O) {
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: relation.many_field });
await fieldsService.deleteField(relation.one_collection, relation.one_field);
await fieldsService.deleteField(relation.one_collection!, relation.one_field!);
} else {
await this.knex('directus_relations')
.update({ one_field: null })

View File

@@ -325,11 +325,13 @@ export class FieldsService {
for (const relation of relations) {
const isM2O = relation.many_collection === collection && relation.many_field === field;
/** @TODO M2A — Handle m2a case here */
if (isM2O) {
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: field });
await this.deleteField(relation.one_collection, relation.one_field);
await this.deleteField(relation.one_collection!, relation.one_field!);
} else {
await this.knex('directus_relations')
.update({ one_field: null })

View File

@@ -1,7 +1,37 @@
import Knex from 'knex';
import database from '../database';
import { AbstractServiceOptions, Accountability, Collection, Field, Relation, Query, AbstractService } from '../types';
import { GraphQLString, GraphQLSchema, GraphQLObjectType, GraphQLList, GraphQLResolveInfo, GraphQLInputObjectType, ObjectFieldNode, GraphQLID, ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, IntValueNode, StringValueNode, BooleanValueNode, ArgumentNode, GraphQLScalarType, GraphQLBoolean, ObjectValueNode } from 'graphql';
import {
AbstractServiceOptions,
Accountability,
Collection,
Field,
Relation,
Query,
AbstractService,
} from '../types';
import {
GraphQLString,
GraphQLSchema,
GraphQLObjectType,
GraphQLList,
GraphQLResolveInfo,
GraphQLInputObjectType,
ObjectFieldNode,
GraphQLID,
ValueNode,
FieldNode,
GraphQLFieldConfigMap,
GraphQLInt,
IntValueNode,
StringValueNode,
BooleanValueNode,
ArgumentNode,
GraphQLScalarType,
GraphQLBoolean,
ObjectValueNode,
GraphQLUnionType,
GraphQLUnionTypeConfig,
} from 'graphql';
import { getGraphQLType } from '../utils/get-graphql-type';
import { RelationsService } from './relations';
import { ItemsService } from './items';
@@ -21,6 +51,8 @@ import { SettingsService } from './settings';
import { UsersService } from './users';
import { WebhooksService } from './webhooks';
import { getRelationType } from '../utils/get-relation-type';
export class GraphQLService {
accountability: Accountability | null;
knex: Knex;
@@ -38,7 +70,7 @@ export class GraphQLService {
args = {
sort: {
type: GraphQLString
type: GraphQLString,
},
limit: {
type: GraphQLInt,
@@ -51,15 +83,19 @@ export class GraphQLService {
},
search: {
type: GraphQLString,
}
}
},
};
async getSchema() {
const collectionsInSystem = await this.collectionsService.readByQuery();
const fieldsInSystem = await this.fieldsService.readAll();
const relationsInSystem = await this.relationsService.readByQuery({}) as Relation[];
const relationsInSystem = (await this.relationsService.readByQuery({})) as Relation[];
const schema = this.getGraphQLSchema(collectionsInSystem, fieldsInSystem, relationsInSystem);
const schema = this.getGraphQLSchema(
collectionsInSystem,
fieldsInSystem,
relationsInSystem
);
return schema;
}
@@ -77,27 +113,46 @@ export class GraphQLService {
description: collection.meta?.note,
fields: () => {
const fieldsObject: GraphQLFieldConfigMap<any, any> = {};
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
const fieldsInCollection = fields.filter(
(field) => field.collection === collection.collection
);
for (const field of fieldsInCollection) {
const relationForField = relations.find((relation) => {
return relation.many_collection === collection.collection && relation.many_field === field.field ||
relation.one_collection === collection.collection && relation.one_field === field.field;
return (
(relation.many_collection === collection.collection &&
relation.many_field === field.field) ||
(relation.one_collection === collection.collection &&
relation.one_field === field.field)
);
});
if (relationForField) {
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
const relationType = getRelationType({
relation: relationForField,
collection: collection.collection,
field: field.field,
});
if (isM2O) {
const relatedIsSystem = relationForField.one_collection.startsWith('directus_');
const relatedType = relatedIsSystem ? schema[relationForField.one_collection.substring(9)].type : schema.items[relationForField.one_collection].type;
if (relationType === 'm2o') {
const relatedIsSystem = relationForField.one_collection!.startsWith(
'directus_'
);
const relatedType = relatedIsSystem
? schema[relationForField.one_collection!.substring(9)].type
: schema.items[relationForField.one_collection!].type;
fieldsObject[field.field] = {
type: relatedType,
}
} else {
const relatedIsSystem = relationForField.many_collection.startsWith('directus_');
const relatedType = relatedIsSystem ? schema[relationForField.many_collection.substring(9)].type : schema.items[relationForField.many_collection].type;
};
} else if (relationType === 'o2m') {
const relatedIsSystem = relationForField.many_collection.startsWith(
'directus_'
);
const relatedType = relatedIsSystem
? schema[relationForField.many_collection.substring(9)].type
: schema.items[relationForField.many_collection].type;
fieldsObject[field.field] = {
type: new GraphQLList(relatedType),
@@ -105,14 +160,44 @@ export class GraphQLService {
...this.args,
filter: {
type: filterTypes[relationForField.many_collection],
}
},
},
};
} else if (relationType === 'm2a') {
const relatedCollections = relationForField.one_allowed_collections!;
const types: any = [];
for (const relatedCollection of relatedCollections) {
const relatedType = relatedCollection.startsWith(
'directus_'
)
? schema[relatedCollection.substring(9)].type
: schema.items[relatedCollection].type;
types.push(relatedType);
}
fieldsObject[field.field] = {
type: new GraphQLUnionType({
name: field.field,
types,
resolveType(value, _, info) {
/**
* @TODO figure out a way to reach the parent level
* to be able to read one_collection_field
*/
return types[0];
},
}),
};
}
} else {
fieldsObject[field.field] = {
type: field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type),
}
type: field.schema?.is_primary_key
? GraphQLID
: getGraphQLType(field.type),
};
}
fieldsObject[field.field].description = field.meta?.note;
@@ -121,14 +206,15 @@ export class GraphQLService {
return fieldsObject;
},
}),
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => this.resolve(info),
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) =>
this.resolve(info),
args: {
...this.args,
filter: {
name: `${collection.collection}_filter`,
type: filterTypes[collection.collection],
}
}
},
},
};
if (systemCollection) {
@@ -145,9 +231,13 @@ export class GraphQLService {
const systemCollection = collection.collection.startsWith('directus_');
if (systemCollection) {
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(schemaWithLists[collection.collection.substring(9)].type);
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(
schemaWithLists[collection.collection.substring(9)].type
);
} else {
schemaWithLists.items[collection.collection].type = new GraphQLList(schemaWithLists.items[collection.collection].type);
schemaWithLists.items[collection.collection].type = new GraphQLList(
schemaWithLists.items[collection.collection].type
);
}
}
}
@@ -177,39 +267,55 @@ export class GraphQLService {
fields: () => {
const filterFields: any = {
_and: {
type: new GraphQLList(filterTypes[collection.collection])
type: new GraphQLList(filterTypes[collection.collection]),
},
_or: {
type: new GraphQLList(filterTypes[collection.collection])
type: new GraphQLList(filterTypes[collection.collection]),
},
};
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
const fieldsInCollection = fields.filter(
(field) => field.collection === collection.collection
);
for (const field of fieldsInCollection) {
const relationForField = relations.find((relation) => {
return relation.many_collection === collection.collection && relation.many_field === field.field ||
relation.one_collection === collection.collection && relation.one_field === field.field;
return (
(relation.many_collection === collection.collection &&
relation.many_field === field.field) ||
(relation.one_collection === collection.collection &&
relation.one_field === field.field)
);
});
if (relationForField) {
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
const relationType = getRelationType({
relation: relationForField,
collection: collection.collection,
field: field.field,
});
if (isM2O) {
const relatedType = filterTypes[relationForField.one_collection];
if (relationType === 'm2o') {
const relatedType = filterTypes[relationForField.one_collection!];
filterFields[field.field] = {
type: relatedType,
}
} else {
};
} else if (relationType === 'o2m') {
const relatedType = filterTypes[relationForField.many_collection];
filterFields[field.field] = {
type: relatedType
}
type: relatedType,
};
}
/** @TODO M2A — Handle m2a case here */
/** @TODO
* Figure out how to setup filter fields for a union type output
*/
} else {
const fieldType = field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type);
const fieldType = field.schema?.is_primary_key
? GraphQLID
: getGraphQLType(field.type);
filterFields[field.field] = {
type: new GraphQLInputObjectType({
@@ -220,7 +326,7 @@ export class GraphQLService {
type: fieldType,
},
_neq: {
type: fieldType
type: fieldType,
},
_contains: {
type: fieldType,
@@ -257,10 +363,10 @@ export class GraphQLService {
},
_nempty: {
type: GraphQLBoolean,
}
}
},
},
}),
}
};
}
}
@@ -269,20 +375,26 @@ export class GraphQLService {
});
}
return filterTypes
return filterTypes;
}
async resolve(info: GraphQLResolveInfo) {
const systemField = info.path.prev?.key !== 'items';
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter((node) => node.kind === 'Field') as FieldNode[] | undefined;
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter(
(node) => node.kind === 'Field'
) as FieldNode[] | undefined;
if (!selections) return null;
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
}
async getData(collection: string, selections: FieldNode[], argsArray?: readonly ArgumentNode[]) {
async getData(
collection: string,
selections: FieldNode[],
argsArray?: readonly ArgumentNode[]
) {
const args: Record<string, any> = this.parseArgs(argsArray);
const query: Query = sanitizeQuery(args, this.accountability);
@@ -296,7 +408,12 @@ export class GraphQLService {
if (selection.selectionSet === undefined) {
fields.push(current);
} else {
const children = parseFields(selection.selectionSet.selections.filter((selection) => selection.kind === 'Field') as FieldNode[], current);
const children = parseFields(
selection.selectionSet.selections.filter(
(selection) => selection.kind === 'Field'
) as FieldNode[],
current
);
fields.push(...children);
}
@@ -309,47 +426,95 @@ export class GraphQLService {
}
return fields;
}
};
query.fields = parseFields(selections.filter((selection) => selection.kind === 'Field') as FieldNode[]);
query.fields = parseFields(
selections.filter((selection) => selection.kind === 'Field') as FieldNode[]
);
let service: ItemsService;
switch (collection) {
case 'directus_activity':
service = new ActivityService({ knex: this.knex, accountability: this.accountability });
service = new ActivityService({
knex: this.knex,
accountability: this.accountability,
});
// case 'directus_collections':
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
// case 'directus_fields':
// service = new FieldsService({ knex: this.knex, accountability: this.accountability });
case 'directus_files':
service = new FilesService({ knex: this.knex, accountability: this.accountability });
service = new FilesService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_folders':
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
service = new FoldersService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_folders':
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
service = new FoldersService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_permissions':
service = new PermissionsService({ knex: this.knex, accountability: this.accountability });
service = new PermissionsService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_presets':
service = new PresetsService({ knex: this.knex, accountability: this.accountability });
service = new PresetsService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_relations':
service = new RelationsService({ knex: this.knex, accountability: this.accountability });
service = new RelationsService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_revisions':
service = new RevisionsService({ knex: this.knex, accountability: this.accountability });
service = new RevisionsService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_roles':
service = new RolesService({ knex: this.knex, accountability: this.accountability });
service = new RolesService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_settings':
service = new SettingsService({ knex: this.knex, accountability: this.accountability });
service = new SettingsService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_users':
service = new UsersService({ knex: this.knex, accountability: this.accountability });
service = new UsersService({
knex: this.knex,
accountability: this.accountability,
});
case 'directus_webhooks':
service = new WebhooksService({ knex: this.knex, accountability: this.accountability });
service = new WebhooksService({
knex: this.knex,
accountability: this.accountability,
});
default:
service = new ItemsService(collection, { knex: this.knex, accountability: this.accountability });
service = new ItemsService(collection, {
knex: this.knex,
accountability: this.accountability,
});
}
const collectionInfo = await this.knex.select('singleton').from('directus_collections').where({ collection: collection }).first();
const result = collectionInfo?.singleton === true ? await service.readSingleton(query) : await service.readByQuery(query);
const collectionInfo = await this.knex
.select('singleton')
.from('directus_collections')
.where({ collection: collection })
.first();
const result =
collectionInfo?.singleton === true
? await service.readSingleton(query)
: await service.readByQuery(query);
return result;
}
@@ -359,7 +524,7 @@ export class GraphQLService {
const parseObjectValue = (arg: ObjectValueNode) => {
return this.parseArgs(arg.fields);
}
};
const argsObject: any = {};
@@ -379,11 +544,13 @@ export class GraphQLService {
argsObject[argument.name.value] = values;
} else {
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
argsObject[argument.name.value] = (argument.value as
| IntValueNode
| StringValueNode
| BooleanValueNode).value;
}
}
return argsObject;
}
}

View File

@@ -19,4 +19,4 @@ export * from './settings';
export * from './users';
export * from './utils';
export * from './webhooks';
export * from './specifications'
export * from './specifications';

View File

@@ -20,7 +20,7 @@ import logger from '../logger';
import { PayloadService } from './payload';
import { AuthorizationService } from './authorization';
import { pick, clone } from 'lodash';
import { pick, clone, cloneDeep } from 'lodash';
import getDefaultValue from '../utils/get-default-value';
import { InvalidPayloadException } from '../exceptions';
@@ -29,6 +29,7 @@ export class ItemsService implements AbstractService {
knex: Knex;
accountability: Accountability | null;
eventScope: string;
schemaInspector: ReturnType<typeof SchemaInspector>;
constructor(collection: string, options?: AbstractServiceOptions) {
this.collection = collection;
@@ -38,15 +39,16 @@ export class ItemsService implements AbstractService {
? this.collection.substring(9)
: 'items';
this.schemaInspector = SchemaInspector(this.knex);
return this;
}
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async create(data: Partial<Item>): Promise<PrimaryKey>;
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const columns = await schemaInspector.columns(this.collection);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const columns = await this.schemaInspector.columns(this.collection);
let payloads = clone(Array.isArray(data) ? data : [data]);
@@ -220,8 +222,7 @@ export class ItemsService implements AbstractService {
action: PermissionsAction = 'read'
): Promise<null | Item | Item[]> {
query = clone(query);
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const keys = Array.isArray(key) ? key : [key];
if (keys.length === 1) {
@@ -263,9 +264,8 @@ export class ItemsService implements AbstractService {
data: Partial<Item> | Partial<Item>[],
key?: PrimaryKey | PrimaryKey[]
): Promise<PrimaryKey | PrimaryKey[]> {
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const columns = await schemaInspector.columns(this.collection);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const columns = await this.schemaInspector.columns(this.collection);
// Updating one or more items to the same payload
if (data && key) {
@@ -421,12 +421,58 @@ export class ItemsService implements AbstractService {
return keys;
}
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
// Not authenticated:
const itemsService = new ItemsService(this.collection);
let itemsToUpdate = await itemsService.readByQuery(readQuery);
itemsToUpdate = Array.isArray(itemsToUpdate) ? itemsToUpdate : [itemsToUpdate];
const keys: PrimaryKey[] = itemsToUpdate.map(
(item: Partial<Item>) => item[primaryKeyField]
);
return await this.update(data, keys);
}
upsert(data: Partial<Item>): Promise<PrimaryKey>;
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const payloads = Array.isArray(data) ? data : [data];
const primaryKeys: PrimaryKey[] = [];
for (const payload of payloads) {
const primaryKey = payload[primaryKeyField];
const exists =
primaryKey &&
!!(await this.knex
.select(primaryKeyField)
.from(this.collection)
.where({ [primaryKeyField]: primaryKey })
.first());
if (exists) {
const keys = await this.update([payload]);
primaryKeys.push(...keys);
} else {
const key = await this.create(payload);
primaryKeys.push(key);
}
}
return Array.isArray(data) ? primaryKeys : primaryKeys[0];
}
delete(key: PrimaryKey): Promise<PrimaryKey>;
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
const keys = (Array.isArray(key) ? key : [key]) as PrimaryKey[];
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
if (this.accountability && this.accountability.admin !== true) {
const authorizationService = new AuthorizationService({
@@ -480,15 +526,31 @@ export class ItemsService implements AbstractService {
return key;
}
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const readQuery = cloneDeep(query);
readQuery.fields = [primaryKeyField];
// Not authenticated:
const itemsService = new ItemsService(this.collection);
let itemsToDelete = await itemsService.readByQuery(readQuery);
itemsToDelete = Array.isArray(itemsToDelete) ? itemsToDelete : [itemsToDelete];
const keys: PrimaryKey[] = itemsToDelete.map(
(item: Partial<Item>) => item[primaryKeyField]
);
return await this.delete(keys);
}
async readSingleton(query: Query) {
query = clone(query);
const schemaInspector = SchemaInspector(this.knex);
query.single = true;
const record = (await this.readByQuery(query)) as Item;
if (!record) {
const columns = await schemaInspector.columnInfo(this.collection);
const columns = await this.schemaInspector.columnInfo(this.collection);
const defaults: Record<string, any> = {};
for (const column of columns) {
@@ -502,8 +564,7 @@ export class ItemsService implements AbstractService {
}
async upsertSingleton(data: Partial<Item>) {
const schemaInspector = SchemaInspector(this.knex);
const primaryKeyField = await schemaInspector.primary(this.collection);
const primaryKeyField = await this.schemaInspector.primary(this.collection);
const record = await this.knex
.select(primaryKeyField)

View File

@@ -6,7 +6,7 @@
import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import database from '../database';
import { clone, isObject } from 'lodash';
import { clone, isObject, cloneDeep } from 'lodash';
import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey } from '../types';
import { ItemsService } from './items';
import { URL } from 'url';
@@ -15,6 +15,7 @@ import env from '../env';
import SchemaInspector from 'knex-schema-inspector';
import getLocalType from '../utils/get-local-type';
import { format, formatISO } from 'date-fns';
import { ForbiddenException } from '../exceptions';
type Action = 'create' | 'read' | 'update';
@@ -301,6 +302,8 @@ export class PayloadService {
});
for (const relation of relationsToProcess) {
if (!relation.one_collection || !relation.one_primary) continue;
const itemsService = new ItemsService(relation.one_collection, {
accountability: this.accountability,
knex: this.knex,
@@ -313,11 +316,7 @@ export class PayloadService {
const exists = hasPrimaryKey && !!(await itemsService.readByKey(relatedPrimaryKey));
if (exists) {
if (relatedRecord.hasOwnProperty('$delete') && relatedRecord.$delete) {
await itemsService.delete(relatedPrimaryKey);
} else {
await itemsService.update(relatedRecord, relatedPrimaryKey);
}
await itemsService.update(relatedRecord, relatedPrimaryKey);
} else {
relatedPrimaryKey = await itemsService.create(relatedRecord);
}
@@ -346,6 +345,8 @@ export class PayloadService {
// Only process related records that are actually in the payload
const relationsToProcess = relations.filter((relation) => {
if (!relation.one_field) return false;
return (
payload.hasOwnProperty(relation.one_field) &&
Array.isArray(payload[relation.one_field])
@@ -353,48 +354,52 @@ export class PayloadService {
});
for (const relation of relationsToProcess) {
const relatedRecords: Partial<Item>[] = payload[relation.one_field].map(
(record: string | number | Partial<Item>) => {
if (typeof record === 'string' || typeof record === 'number') {
record = {
[relation.many_primary]: record,
};
}
return {
...record,
[relation.many_field]: parent || payload[relation.one_primary],
};
}
);
const itemsService = new ItemsService(relation.many_collection, {
accountability: this.accountability,
knex: this.knex,
});
const toBeCreated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === false
const relatedRecords: Partial<Item>[] = [];
for (const relatedRecord of payload[relation.one_field!]) {
let record = cloneDeep(relatedRecord);
if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
const exists = !!(await this.knex
.select(relation.many_primary)
.from(relation.many_collection)
.where({ [relation.many_primary]: record })
.first());
if (exists === false)
throw new ForbiddenException(undefined, {
item: record,
collection: relation.many_collection,
});
record = {
[relation.many_primary]: relatedRecord,
};
}
relatedRecords.push({
...record,
[relation.many_field]: parent || payload[relation.one_primary!],
});
}
const primaryKeys = await itemsService.upsert(relatedRecords);
await itemsService.updateByQuery(
{ [relation.many_field]: null },
{
filter: {
[relation.many_primary]: {
_nin: primaryKeys,
},
},
}
);
const toBeUpdated = relatedRecords.filter(
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') === false
);
const toBeDeleted = relatedRecords
.filter(
(record) =>
record.hasOwnProperty(relation.many_primary) === true &&
record.hasOwnProperty('$delete') &&
record.$delete === true
)
.map((record) => record[relation.many_primary]);
await itemsService.create(toBeCreated);
await itemsService.update(toBeUpdated);
await itemsService.delete(toBeDeleted);
}
}
}

View File

@@ -22,7 +22,8 @@ export class RelationsService extends ItemsService {
}
async readByQuery(query: Query): Promise<null | Relation | Relation[]> {
const results = (await super.readByQuery(query)) as Relation | Relation[] | null;
const service = new ItemsService('directus_relations', { knex: this.knex });
const results = (await service.readByQuery(query)) as Relation | Relation[] | null;
const filteredResults = await this.filterForbidden(results);
return filteredResults;
}
@@ -38,10 +39,12 @@ export class RelationsService extends ItemsService {
query: Query = {},
action: PermissionsAction = 'read'
): Promise<null | Relation | Relation[]> {
const results = (await super.readByKey(key as any, query, action)) as
const service = new ItemsService('directus_relations', { knex: this.knex });
const results = (await service.readByKey(key as any, query, action)) as
| Relation
| Relation[]
| null;
const filteredResults = await this.filterForbidden(results);
return filteredResults;
}
@@ -62,17 +65,47 @@ export class RelationsService extends ItemsService {
relations = Array.isArray(relations) ? relations : [relations];
return relations.filter((relation) => {
const collectionsAllowed =
allowedCollections.includes(relation.many_collection) &&
allowedCollections.includes(relation.one_collection);
let collectionsAllowed = true;
let fieldsAllowed = true;
const fieldsAllowed =
allowedFields[relation.one_collection] &&
allowedFields[relation.many_collection] &&
(allowedFields[relation.many_collection].includes('*') ||
allowedFields[relation.many_collection].includes(relation.many_field)) &&
(allowedFields[relation.one_collection].includes('*') ||
allowedFields[relation.one_collection].includes(relation.one_field));
if (allowedCollections.includes(relation.many_collection) === false) {
collectionsAllowed = false;
}
if (
relation.one_collection &&
allowedCollections.includes(relation.one_collection) === false
) {
collectionsAllowed = false;
}
if (
relation.one_allowed_collections &&
relation.one_allowed_collections.split(',').every(allowedCollections.includes) ===
false
) {
collectionsAllowed = false;
}
if (
!allowedFields[relation.many_collection] ||
allowedFields[relation.many_collection].includes('*') ||
allowedFields[relation.many_collection].includes(relation.many_field) === false
) {
fieldsAllowed = false;
}
if (
relation.one_collection &&
relation.one_field &&
(!allowedFields[relation.one_collection] ||
allowedFields[relation.one_collection].includes('*') ||
allowedFields[relation.one_collection].includes(relation.one_field) === false)
) {
fieldsAllowed = false;
}
/** @TODO M2A — Handle m2a case here */
return collectionsAllowed && fieldsAllowed;
});

View File

@@ -3,6 +3,7 @@ import {
Accountability,
Collection,
Field,
Permission,
Relation,
types,
} from '../types';
@@ -12,17 +13,25 @@ import formatTitle from '@directus/format-title';
import { cloneDeep, mergeWith } from 'lodash';
import { RelationsService } from './relations';
import env from '../env';
import {
OpenAPIObject,
PathItemObject,
OperationObject,
TagObject,
SchemaObject,
} from 'openapi3-ts';
// @ts-ignore
import { version } from '../../package.json';
// @ts-ignore
import openapi from '@directus/specs';
type RelationTree = Record<string, Record<string, Relation[]>>;
import Knex from 'knex';
import database from '../database';
import { getRelationType } from '../utils/get-relation-type';
export class SpecificationService {
accountability: Accountability | null;
knex: Knex;
fieldsService: FieldsService;
collectionsService: CollectionsService;
@@ -32,16 +41,20 @@ export class SpecificationService {
constructor(options?: AbstractServiceOptions) {
this.accountability = options?.accountability || null;
this.knex = options?.knex || database;
this.fieldsService = new FieldsService(options);
this.collectionsService = new CollectionsService(options);
this.relationsService = new RelationsService(options);
this.oas = new OASService({
fieldsService: this.fieldsService,
collectionsService: this.collectionsService,
relationsService: this.relationsService,
});
this.oas = new OASService(
{ knex: this.knex, accountability: this.accountability },
{
fieldsService: this.fieldsService,
collectionsService: this.collectionsService,
relationsService: this.relationsService,
}
);
}
}
@@ -50,34 +63,462 @@ interface SpecificationSubService {
}
class OASService implements SpecificationSubService {
accountability: Accountability | null;
knex: Knex;
fieldsService: FieldsService;
collectionsService: CollectionsService;
relationsService: RelationsService;
constructor({
fieldsService,
collectionsService,
relationsService,
}: {
fieldsService: FieldsService;
collectionsService: CollectionsService;
relationsService: RelationsService;
}) {
constructor(
options: AbstractServiceOptions,
{
fieldsService,
collectionsService,
relationsService,
}: {
fieldsService: FieldsService;
collectionsService: CollectionsService;
relationsService: RelationsService;
}
) {
this.accountability = options.accountability || null;
this.knex = options?.knex || database;
this.fieldsService = fieldsService;
this.collectionsService = collectionsService;
this.relationsService = relationsService;
}
private collectionsDenyList = [
'directus_collections',
'directus_fields',
'directus_migrations',
'directus_sessions',
];
async generate() {
const collections = await this.collectionsService.readByQuery();
const fields = await this.fieldsService.readAll();
const relations = (await this.relationsService.readByQuery({})) as Relation[];
const permissions: Permission[] = await this.knex
.select('*')
.from('directus_permissions')
.where({ role: this.accountability?.role || null });
const tags = await this.generateTags(collections);
const paths = await this.generatePaths(permissions, tags);
const components = await this.generateComponents(collections, fields, relations, tags);
const spec: OpenAPIObject = {
openapi: '3.0.1',
info: {
title: 'Dynamic API Specification',
description:
'This is a dynamicly generated API specification for all endpoints existing on the current .',
version: version,
},
servers: [
{
url: env.PUBLIC_URL,
description: 'Your current Directus instance.',
},
],
tags,
paths,
components,
};
return spec;
}
private async generateTags(collections: Collection[]): Promise<OpenAPIObject['tags']> {
const systemTags = cloneDeep(openapi.tags)!;
const tags: OpenAPIObject['tags'] = [];
// System tags that don't have an associated collection are always readable to the user
for (const systemTag of systemTags) {
if (!systemTag['x-collection']) {
tags.push(systemTag);
}
}
for (const collection of collections) {
const isSystem = collection.collection.startsWith('directus_');
// If the collection is one of the system collections, pull the tag from the static spec
if (isSystem) {
for (const tag of openapi.tags!) {
if (tag['x-collection'] === collection.collection) {
tags.push(tag);
break;
}
}
} else {
tags.push({
name: 'Items' + formatTitle(collection.collection).replace(/ /g, ''),
description: collection.meta?.note || undefined,
'x-collection': collection.collection,
});
}
}
// Filter out the generic Items information
return tags.filter((tag) => tag.name !== 'Items');
}
private async generatePaths(
permissions: Permission[],
tags: OpenAPIObject['tags']
): Promise<OpenAPIObject['paths']> {
const paths: OpenAPIObject['paths'] = {};
if (!tags) return paths;
for (const tag of tags) {
const isSystem =
tag.hasOwnProperty('x-collection') === false ||
tag['x-collection'].startsWith('directus_');
if (isSystem) {
for (const [path, pathItem] of Object.entries<PathItemObject>(openapi.paths)) {
for (const [method, operation] of Object.entries<OperationObject>(pathItem)) {
if (operation.tags?.includes(tag.name)) {
if (!paths[path]) {
paths[path] = {};
}
const hasPermission =
this.accountability?.admin === true ||
tag.hasOwnProperty('x-collection') === false ||
!!permissions.find(
(permission) =>
permission.collection === tag['x-collection'] &&
permission.action === this.getActionForMethod(method)
);
if (hasPermission) {
paths[path][method] = operation;
}
}
}
}
} else {
const listBase = cloneDeep(openapi.paths['/items/{collection}']);
const detailBase = cloneDeep(openapi.paths['/items/{collection}/{id}']);
const collection = tag['x-collection'];
for (const method of ['post', 'get', 'patch', 'delete']) {
const hasPermission =
this.accountability?.admin === true ||
!!permissions.find(
(permission) =>
permission.collection === collection &&
permission.action === this.getActionForMethod(method)
);
if (hasPermission) {
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],
operationId: `${this.getActionForMethod(method)}${tag.name}`,
requestBody: ['get', 'delete'].includes(method)
? undefined
: {
content: {
'application/json': {
schema: {
oneOf: [
{
type: 'array',
items: {
$ref: `#/components/schema/${tag.name}`,
},
},
{
$ref: `#/components/schema/${tag.name}`,
},
],
},
},
},
},
responses: {
'200': {
content:
method === 'delete'
? undefined
: {
'application/json': {
schema: {
properties: {
data: {
items: {
$ref: `#/components/schema/${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
}`,
requestBody: ['get', 'delete'].includes(method)
? undefined
: {
content: {
'application/json': {
schema: {
$ref: `#/components/schema/${tag.name}`,
},
},
},
},
responses: {
'200': {
content:
method === 'delete'
? undefined
: {
'application/json': {
schema: {
properties: {
data: {
items: {
$ref: `#/components/schema/${tag.name}`,
},
},
},
},
},
},
},
},
},
(obj, src) => {
if (Array.isArray(obj)) return obj.concat(src);
}
);
}
}
}
}
}
return paths;
}
private async generateComponents(
collections: Collection[],
fields: Field[],
relations: Relation[],
tags: OpenAPIObject['tags']
): Promise<OpenAPIObject['components']> {
let components: OpenAPIObject['components'] = cloneDeep(openapi.components);
if (!components) components = {};
components.schemas = {};
if (!tags) return;
for (const collection of collections) {
const tag = tags.find((tag) => tag['x-collection'] === collection.collection);
if (!tag) continue;
const isSystem = collection.collection.startsWith('directus_');
const fieldsInCollection = fields.filter(
(field) => field.collection === collection.collection
);
if (isSystem) {
const schemaComponent: SchemaObject = cloneDeep(
openapi.components!.schemas![tag.name]
);
schemaComponent.properties = {};
for (const field of fieldsInCollection) {
schemaComponent.properties[field.field] =
(cloneDeep(
(openapi.components!.schemas![tag.name] as SchemaObject).properties![
field.field
]
) as SchemaObject) || this.generateField(field, relations, tags, fields);
}
components.schemas[tag.name] = schemaComponent;
} else {
const schemaComponent: SchemaObject = {
type: 'object',
properties: {},
'x-collection': collection.collection,
};
for (const field of fieldsInCollection) {
schemaComponent.properties![field.field] = this.generateField(
field,
relations,
tags,
fields
);
}
components.schemas[tag.name] = schemaComponent;
}
}
return components;
}
private getActionForMethod(method: string): 'create' | 'read' | 'update' | 'delete' {
switch (method) {
case 'post':
return 'create';
case 'patch':
return 'update';
case 'delete':
return 'delete';
case 'get':
default:
return 'read';
}
}
private generateField(
field: Field,
relations: Relation[],
tags: TagObject[],
fields: Field[]
): SchemaObject {
let propertyObject: SchemaObject = {
nullable: field.schema?.is_nullable,
description: field.meta?.note || undefined,
};
const relation = relations.find(
(relation) =>
(relation.many_collection === field.collection &&
relation.many_field === field.field) ||
(relation.one_collection === field.collection && relation.one_field === field.field)
);
if (!relation) {
propertyObject = {
...propertyObject,
...this.fieldTypes[field.type],
};
} else {
const relationType = getRelationType({
relation,
field: field.field,
collection: field.collection,
});
if (relationType === 'm2o') {
const relatedTag = tags.find(
(tag) => tag['x-collection'] === relation.one_collection
);
const relatedPrimaryKeyField = fields.find(
(field) =>
field.collection === relation.one_collection && field.schema?.is_primary_key
);
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
propertyObject.oneOf = [
{
...this.fieldTypes[relatedPrimaryKeyField.type],
},
{
$ref: `#/components/schemas/${relatedTag.name}`,
},
];
} else if (relationType === 'o2m') {
const relatedTag = tags.find(
(tag) => tag['x-collection'] === relation.many_collection
);
const relatedPrimaryKeyField = fields.find(
(field) =>
field.collection === relation.many_collection &&
field.schema?.is_primary_key
);
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
propertyObject.type = 'array';
propertyObject.items = {
oneOf: [
{
...this.fieldTypes[relatedPrimaryKeyField.type],
},
{
$ref: `#/components/schemas/${relatedTag.name}`,
},
],
};
} else if (relationType === 'm2a') {
const relatedTags = tags.filter((tag) =>
relation.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;
}
private fieldTypes: Record<
typeof types[number],
{ type: string; format?: string; items?: any }
{
type:
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'integer'
| 'null'
| undefined;
format?: string;
items?: any;
}
> = {
bigInteger: {
type: 'integer',
@@ -139,311 +580,4 @@ class OASService implements SpecificationSubService {
},
},
};
async generate() {
const collections = await this.collectionsService.readByQuery();
const userCollections = collections.filter(
(collection) =>
collection.collection.startsWith('directus_') === false ||
this.collectionsDenyList.includes(collection.collection) === false
);
const allFields = await this.fieldsService.readAll();
const fields: Record<string, Field[]> = {};
for (const field of allFields) {
if (
field.collection.startsWith('directus_') === false ||
this.collectionsDenyList.includes(field.collection) === false
) {
if (field.collection in fields) {
fields[field.collection].push(field);
} else {
fields[field.collection] = [field];
}
}
}
const relationsResult = await this.relationsService.readByQuery({});
if (relationsResult === null) return {};
const relations = Array.isArray(relationsResult) ? relationsResult : [relationsResult];
const relationsTree: RelationTree = {};
for (const relation of relations as Relation[]) {
if (relation.many_collection in relationsTree === false)
relationsTree[relation.many_collection] = {};
if (relation.one_collection in relationsTree === false)
relationsTree[relation.one_collection] = {};
if (relation.many_field in relationsTree[relation.many_collection] === false)
relationsTree[relation.many_collection][relation.many_field] = [];
if (relation.one_field in relationsTree[relation.one_collection] === false)
relationsTree[relation.one_collection][relation.one_field] = [];
relationsTree[relation.many_collection][relation.many_field].push(relation);
relationsTree[relation.one_collection][relation.one_field].push(relation);
}
const dynOpenapi = {
openapi: '3.0.1',
info: {
title: 'Dynamic Api Specification',
description:
'This is a dynamicly generated api specification for all endpoints existing on the api.',
version: version,
},
servers: [
{
url: env.PUBLIC_URL,
description: 'Your current api server.',
},
],
tags: this.generateTags(userCollections),
paths: this.generatePaths(userCollections),
components: {
schemas: this.generateSchemas(userCollections, fields, relationsTree),
},
};
return mergeWith(cloneDeep(openapi), cloneDeep(dynOpenapi), (obj, src) => {
if (Array.isArray(obj)) return obj.concat(src);
});
}
private getNameFormats(collection: string) {
const isInternal = collection.startsWith('directus_');
const schema = formatTitle(
isInternal ? collection.replace('directus_', '').replace(/s$/, '') : collection + 'Item'
).replace(/ /g, '');
const tag = formatTitle(
isInternal ? collection.replace('directus_', '') : collection + ' Collection'
);
const path = isInternal ? collection : '/items/' + collection;
const objectRef = `#/components/schemas/${schema}`;
return { schema, tag, path, objectRef };
}
private generateTags(collections: Collection[]) {
const tags: { name: string; description?: string }[] = [];
for (const collection of collections) {
if (collection.collection.startsWith('directus_')) continue;
const { tag } = this.getNameFormats(collection.collection);
tags.push({ name: tag, description: collection.meta?.note || undefined });
}
return tags;
}
private generatePaths(collections: Collection[]) {
const paths: Record<string, object> = {};
for (const collection of collections) {
if (collection.collection.startsWith('directus_')) continue;
const { tag, schema, objectRef, path } = this.getNameFormats(collection.collection);
const objectSingle = {
content: {
'application/json': {
schema: {
$ref: objectRef,
},
},
},
};
(paths[path] = {
get: {
operationId: `get${schema}s`,
description: `List all items from the ${tag}`,
tags: [tag],
parameters: [
{ $ref: '#/components/parameters/Fields' },
{ $ref: '#/components/parameters/Limit' },
{ $ref: '#/components/parameters/Meta' },
{ $ref: '#/components/parameters/Offset' },
{ $ref: '#/components/parameters/Single' },
{ $ref: '#/components/parameters/Sort' },
{ $ref: '#/components/parameters/Filter' },
{ $ref: '#/components/parameters/q' },
],
responses: {
'200': {
description: 'Successful request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: {
$ref: objectRef,
},
},
},
},
},
},
},
'401': {
$ref: '#/components/responses/UnauthorizedError',
},
},
},
post: {
operationId: `create${schema}`,
description: `Create a new item in the ${tag}`,
tags: [tag],
parameter: [{ $ref: '#/components/parameters/Meta' }],
requestBody: objectSingle,
responses: {
'200': objectSingle,
'401': {
$ref: '#/components/responses/UnauthorizedError',
},
},
},
}),
(paths[path + '/{id}'] = {
parameters: [{ $ref: '#/components/parameters/Id' }],
get: {
operationId: `get${schema}`,
description: `Get a singe item from the ${tag}`,
tags: [tag],
parameters: [
{ $ref: '#/components/parameters/Fields' },
{ $ref: '#/components/parameters/Meta' },
],
responses: {
'200': objectSingle,
'401': {
$ref: '#/components/responses/UnauthorizedError',
},
'404': {
$ref: '#/components/responses/NotFoundError',
},
},
},
patch: {
operationId: `update${schema}`,
description: `Update an item from the ${tag}`,
tags: [tag],
parameters: [
{ $ref: '#/components/parameters/Fields' },
{ $ref: '#/components/parameters/Meta' },
],
requestBody: objectSingle,
responses: {
'200': objectSingle,
'401': {
$ref: '#/components/responses/UnauthorizedError',
},
'404': {
$ref: '#/components/responses/NotFoundError',
},
},
},
delete: {
operationId: `delete${schema}`,
description: `Delete an item from the ${tag}`,
tags: [tag],
responses: {
'200': {
description: 'Successful request',
},
'401': {
$ref: '#/components/responses/UnauthorizedError',
},
'404': {
$ref: '#/components/responses/NotFoundError',
},
},
},
});
}
return paths;
}
private generateSchemas(
collections: Collection[],
fields: Record<string, Field[]>,
relations: RelationTree
) {
const schemas: Record<string, any> = {};
for (const collection of collections) {
const { schema, tag } = this.getNameFormats(collection.collection);
if (fields === undefined) return;
schemas[schema] = {
type: 'object',
'x-tag': tag,
properties: {},
};
for (const field of fields[collection.collection]) {
const fieldRelations =
field.collection in relations && field.field in relations[field.collection]
? relations[field.collection][field.field]
: [];
if (fieldRelations.length !== 0) {
const relation = fieldRelations[0];
const isM2O =
relation.many_collection === field.collection &&
relation.many_field === field.field;
const relatedCollection = isM2O
? relation.one_collection
: relation.many_collection;
if (!relatedCollection) continue;
const relatedPrimaryField = fields[relatedCollection].find(
(field) => field.schema?.is_primary_key
);
if (relatedPrimaryField?.type === undefined) continue;
const relatedType = this.fieldTypes[relatedPrimaryField?.type];
const { objectRef } = this.getNameFormats(relatedCollection);
const type = isM2O
? {
oneOf: [
{
...relatedType,
nullable: field.schema?.is_nullable === true,
},
{ $ref: objectRef },
],
}
: {
type: 'array',
items: { $ref: objectRef },
nullable: field.schema?.is_nullable === true,
};
schemas[schema].properties[field.field] = {
...type,
description: field.meta?.note || undefined,
};
} else {
schemas[schema].properties[field.field] = {
...this.fieldTypes[field.type],
nullable: field.schema?.is_nullable === true,
description: field.meta?.note || undefined,
};
}
}
}
return schemas;
}
}

View File

@@ -1,24 +1,55 @@
import { Query } from './query';
import { Relation } from './relation';
export type NestedCollectionAST = {
type: 'collection';
export type M2ONode = {
type: 'm2o';
name: string;
children: (NestedCollectionAST | FieldAST)[];
children: (NestedCollectionNode | FieldNode)[];
query: Query;
fieldKey: string;
relation: Relation;
parentKey: string;
relatedKey: string;
};
export type M2ANode = {
type: 'm2a';
names: string[];
children: {
[collection: string]: (NestedCollectionNode | FieldNode)[];
};
query: {
[collection: string]: Query;
};
relatedKey: {
[collection: string]: string;
};
fieldKey: string;
relation: Relation;
parentKey: string;
};
export type FieldAST = {
export type O2MNode = {
type: 'o2m';
name: string;
children: (NestedCollectionNode | FieldNode)[];
query: Query;
fieldKey: string;
relation: Relation;
parentKey: string;
relatedKey: string;
};
export type NestedCollectionNode = M2ONode | O2MNode | M2ANode;
export type FieldNode = {
type: 'field';
name: string;
};
export type AST = {
type: 'collection';
type: 'root';
name: string;
children: (NestedCollectionAST | FieldAST)[];
children: (NestedCollectionNode | FieldNode)[];
query: Query;
};

View File

@@ -5,7 +5,10 @@ export type Relation = {
many_field: string;
many_primary: string;
one_collection: string;
one_field: string;
one_primary: string;
one_collection: string | null;
one_field: string | null;
one_primary: string | null;
one_collection_field: string | null;
one_allowed_collections: string | null;
};

View File

@@ -4,17 +4,18 @@
import {
AST,
NestedCollectionAST,
FieldAST,
NestedCollectionNode,
FieldNode,
Query,
Relation,
PermissionsAction,
Accountability,
} from '../types';
import database from '../database';
import { clone } from 'lodash';
import { cloneDeep } from 'lodash';
import Knex from 'knex';
import SchemaInspector from 'knex-schema-inspector';
import { getRelationType } from '../utils/get-relation-type';
type GetASTOptions = {
accountability?: Accountability | null;
@@ -27,7 +28,7 @@ export default async function getASTFromQuery(
query: Query,
options?: GetASTOptions
): Promise<AST> {
query = clone(query);
query = cloneDeep(query);
const accountability = options?.accountability;
const action = options?.action || 'read';
@@ -49,7 +50,7 @@ export default async function getASTFromQuery(
: null;
const ast: AST = {
type: 'collection',
type: 'root',
name: collection,
query: query,
children: [],
@@ -62,16 +63,121 @@ export default async function getASTFromQuery(
delete query.fields;
delete query.deep;
ast.children = (await parseFields(collection, fields, deep)).filter(filterEmptyChildCollections);
ast.children = await parseFields(collection, fields, deep);
return ast;
function convertWildcards(parentCollection: string, fields: string[]) {
async function parseFields(
parentCollection: string,
fields: string[],
deep?: Record<string, Query>
) {
fields = await convertWildcards(parentCollection, fields);
if (!fields) return [];
const children: (NestedCollectionNode | FieldNode)[] = [];
const relationalStructure: Record<string, string[]> = {};
for (const field of fields) {
const isRelational =
field.includes('.') ||
!!relations.find(
(relation) =>
(relation.many_collection === parentCollection &&
relation.many_field === field) ||
(relation.one_collection === parentCollection &&
relation.one_field === field)
);
if (isRelational) {
// field is relational
const parts = field.split('.');
if (relationalStructure.hasOwnProperty(parts[0]) === false) {
relationalStructure[parts[0]] = [];
}
if (parts.length > 1) {
relationalStructure[parts[0]].push(parts.slice(1).join('.'));
}
} else {
children.push({ type: 'field', name: field });
}
}
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
const relation = getRelation(parentCollection, relationalField);
if (!relation) continue;
const relationType = getRelationType({
relation,
collection: parentCollection,
field: relationalField,
});
if (!relationType) continue;
let child: NestedCollectionNode | null = null;
if (relationType === 'm2a') {
const allowedCollections = relation.one_allowed_collections!.split(',');
child = {
type: 'm2a',
names: allowedCollections,
children: {},
query: {},
relatedKey: {},
parentKey: await schemaInspector.primary(parentCollection),
fieldKey: relationalField,
relation: relation,
};
for (const relatedCollection of allowedCollections) {
child.children[relatedCollection] = await parseFields(
relatedCollection,
nestedFields
);
child.query[relatedCollection] = {};
child.relatedKey[relatedCollection] = await schemaInspector.primary(
relatedCollection
);
}
} else if (relatedCollection) {
child = {
type: relationType,
name: relatedCollection,
fieldKey: relationalField,
parentKey: await schemaInspector.primary(parentCollection),
relatedKey: await schemaInspector.primary(relatedCollection),
relation: relation,
query: deep?.[relationalField] || {},
children: await parseFields(relatedCollection, nestedFields),
};
}
if (child) {
children.push(child);
}
}
return children;
}
async function convertWildcards(parentCollection: string, fields: string[]) {
fields = cloneDeep(fields);
const fieldsInCollection = await getFieldsInCollection(parentCollection);
const allowedFields = permissions
? permissions
.find((permission) => parentCollection === permission.collection)
?.fields?.split(',')
: ['*'];
: fieldsInCollection;
if (!allowedFields || allowedFields.length === 0) return [];
@@ -81,8 +187,13 @@ export default async function getASTFromQuery(
if (fieldKey.includes('*') === false) continue;
if (fieldKey === '*') {
if (allowedFields.includes('*')) continue;
fields.splice(index, 1, ...allowedFields);
// Set to all fields in collection
if (allowedFields.includes('*')) {
fields.splice(index, 1, ...fieldsInCollection);
} else {
// Set to all allowed fields
fields.splice(index, 1, ...allowedFields);
}
}
// Swap *.* case for *,<relational-field>.*,<another-relational>.*
@@ -97,8 +208,8 @@ export default async function getASTFromQuery(
relation.one_collection === parentCollection
)
.map((relation) => {
const isM2O = relation.many_collection === parentCollection;
return isM2O ? relation.many_field : relation.one_field;
const isMany = relation.many_collection === parentCollection;
return isMany ? relation.many_field : relation.one_field;
})
: allowedFields.filter((fieldKey) => !!getRelation(parentCollection, fieldKey));
@@ -122,57 +233,6 @@ export default async function getASTFromQuery(
return fields;
}
async function parseFields(parentCollection: string, fields: string[], deep?: Record<string, Query>) {
fields = convertWildcards(parentCollection, fields);
if (!fields) return [];
const children: (NestedCollectionAST | FieldAST)[] = [];
const relationalStructure: Record<string, string[]> = {};
for (const field of fields) {
if (field.includes('.') === false) {
children.push({ type: 'field', name: field });
} else {
// field is relational
const parts = field.split('.');
if (relationalStructure.hasOwnProperty(parts[0]) === false) {
relationalStructure[parts[0]] = [];
}
relationalStructure[parts[0]].push(parts.slice(1).join('.'));
}
}
for (const [relationalField, nestedFields] of Object.entries(relationalStructure)) {
const relatedCollection = getRelatedCollection(parentCollection, relationalField);
if (!relatedCollection) continue;
const relation = getRelation(parentCollection, relationalField);
if (!relation) continue;
const child: NestedCollectionAST = {
type: 'collection',
name: relatedCollection,
fieldKey: relationalField,
parentKey: await schemaInspector.primary(parentCollection),
relation: relation,
query: deep?.[relationalField] || {},
children: (await parseFields(relatedCollection, nestedFields)).filter(
filterEmptyChildCollections
),
};
children.push(child);
}
return children;
}
function getRelation(collection: string, field: string) {
const relation = relations.find((relation) => {
return (
@@ -184,22 +244,35 @@ export default async function getASTFromQuery(
return relation;
}
function getRelatedCollection(collection: string, field: string) {
function getRelatedCollection(collection: string, field: string): string | null {
const relation = getRelation(collection, field);
if (!relation) return null;
if (relation.many_collection === collection && relation.many_field === field) {
return relation.one_collection;
return relation.one_collection || null;
}
if (relation.one_collection === collection && relation.one_field === field) {
return relation.many_collection;
return relation.many_collection || null;
}
return null;
}
function filterEmptyChildCollections(childAST: FieldAST | NestedCollectionAST) {
if (childAST.type === 'collection' && childAST.children.length === 0) return false;
return true;
async function getFieldsInCollection(collection: string) {
const columns = (await schemaInspector.columns(collection)).map((column) => column.column);
const fields = (
await database.select('field').from('directus_fields').where({ collection })
).map((field) => field.field);
const fieldsInCollection = [
...columns,
...fields.filter((field) => {
return columns.includes(field) === false;
}),
];
return fieldsInCollection;
}
}

View File

@@ -0,0 +1,29 @@
import { Relation } from '../types';
export function getRelationType(getRelationOptions: {
relation: Relation;
collection: string | null;
field: string;
}): 'm2o' | 'o2m' | 'm2a' | null {
const { relation, collection, field } = getRelationOptions;
if (!relation) return null;
if (
relation.many_collection === collection &&
relation.many_field === field &&
relation.one_collection_field !== null
) {
return 'm2a';
}
if (relation.many_collection === collection && relation.many_field === field) {
return 'm2o';
}
if (relation.one_collection === collection && relation.one_field === field) {
return 'o2m';
}
return null;
}

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g fill="none" fill-rule="evenodd">
<path fill="#ECEFF1" d="M0 0h64v64H0z"/>
<path d="M32 12a20 20 0 100 40 20 20 0 000-40zm-9.86 32.56C23 42.76 28.24 41 32 41s9.02 1.76 9.86 3.56a15.8 15.8 0 01-19.72 0zm22.58-2.9C41.86 38.18 34.92 37 32 37s-9.86 1.18-12.72 4.66A16.02 16.02 0 1148 32a15.9 15.9 0 01-3.28 9.66zM32 20c-3.88 0-7 3.12-7 7s3.12 7 7 7 7-3.12 7-7-3.12-7-7-7zm0 10a3 3 0 110-6 3 3 0 010 6z" fill="#B0BEC5"/>
<path d="M32 12a20 20 0 100 40 20 20 0 000-40zm-9.86 32.56C23 42.76 28.24 41 32 41s9.02 1.76 9.86 3.56a15.8 15.8 0 01-19.72 0zm22.58-2.9C41.86 38.18 34.92 37 32 37s-9.86 1.18-12.72 4.66A16.02 16.02 0 1148 32a15.9 15.9 0 01-3.28 9.66zM32 20c-3.88 0-7 3.12-7 7s3.12 7 7 7 7-3.12 7-7-3.12-7-7-7zm0 10a3 3 0 110-6 3 3 0 010 6z" fill="#B0BEC5"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,6 +1,14 @@
<template>
<div class="v-list-group">
<v-list-item :active="active" class="activator" :to="to" :exact="exact" @click="onClick" :disabled="disabled">
<v-list-item
:active="active"
class="activator"
:to="to"
:exact="exact"
@click="onClick"
:disabled="disabled"
:dense="dense"
>
<slot name="activator" :active="groupActive" />
<v-list-item-icon class="activator-icon" :class="{ active: groupActive }" v-if="$slots.default">
@@ -15,8 +23,8 @@
</template>
<script lang="ts">
import { defineComponent, toRefs, watch } from '@vue/composition-api';
import { useGroupableParent, useGroupable } from '@/composables/groupable';
import { defineComponent, nextTick, toRefs, watch, PropType, ref } from '@vue/composition-api';
import { useGroupable } from '@/composables/groupable';
export default defineComponent({
props: {
@@ -40,10 +48,6 @@ export default defineComponent({
type: Boolean,
default: false,
},
disableGroupableParent: {
type: Boolean,
default: false,
},
scope: {
type: String,
default: undefined,
@@ -52,22 +56,19 @@ export default defineComponent({
type: [String, Number],
default: undefined,
},
dense: {
type: Boolean,
default: false,
},
},
setup(props, { listeners, emit }) {
const { multiple } = toRefs(props);
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
group: props.scope,
value: props.value,
});
if (props.disableGroupableParent !== true) {
useGroupableParent(
{},
{
multiple: toRefs(props).multiple,
}
);
}
return { groupActive, toggle, onClick };
function onClick(event: MouseEvent) {

View File

@@ -25,6 +25,7 @@
<script lang="ts">
import { Location } from 'vue-router';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useGroupable } from '@/composables/groupable';
export default defineComponent({
props: {
@@ -64,6 +65,10 @@ export default defineComponent({
type: String,
default: null,
},
value: {
type: [String, Number],
default: undefined,
},
},
setup(props, { listeners }) {
const component = computed<string>(() => {
@@ -72,6 +77,10 @@ export default defineComponent({
return 'li';
});
const { active: groupActive, toggle, activate, deactivate } = useGroupable({
value: props.value,
});
const isClickable = computed(() => Boolean(props.to || props.href || listeners.click !== undefined));
return { component, isClickable };

View File

@@ -9,7 +9,15 @@ import { defineComponent, PropType, ref, toRefs } from '@vue/composition-api';
import { useGroupableParent } from '@/composables/groupable';
export default defineComponent({
model: {
prop: 'activeItems',
event: 'input',
},
props: {
activeItems: {
type: Array as PropType<(number | string)[]>,
default: () => [],
},
large: {
type: Boolean,
default: false,
@@ -18,13 +26,23 @@ export default defineComponent({
type: Boolean,
default: true,
},
mandatory: {
type: Boolean,
default: true,
},
},
setup(props) {
setup(props, { emit }) {
const { activeItems, multiple, mandatory } = toRefs(props);
useGroupableParent(
{},
{
mandatory: ref(false),
multiple: toRefs(props).multiple,
selection: activeItems,
onSelectionChange: (newSelection) => {
emit('input', newSelection);
},
},
{
mandatory,
multiple,
}
);

View File

@@ -83,9 +83,14 @@ function mapKeys(key: string) {
function callHandlers(event: KeyboardEvent) {
Object.entries(handlers).forEach(([key, value]) => {
const rest = key.split('+').filter((keySegment) => keysdown.has(keySegment) === false);
const keys = key.split('+');
if (rest.length > 0) return;
for (key of keysdown) {
if (keys.includes(key) === false) return;
}
for (key of keys) {
if (keysdown.has(key) === false) return;
}
for (let i = 0; i < value.length; i++) {
let cancel = false;

View File

@@ -55,6 +55,7 @@ export default defineComponent({
if (props.value.avatar?.id) {
return `${getRootPath()}assets/${props.value.avatar.id}?key=system-small-cover`;
}
return null;
});
return { src };

View File

@@ -25,7 +25,7 @@ export default defineComponent({
},
placeholder: {
type: String,
default: i18n.t('empty_item'),
default: null,
},
toggle: {
type: Function,

View File

@@ -7,6 +7,7 @@
:toggle="toggle"
@delete="$emit('delete')"
:disabled="disabled"
:placeholder="headerPlaceholder"
/>
<transition-expand>
<div v-if="active">
@@ -46,6 +47,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
headerPlaceholder: {
type: String,
default: null,
},
},
});
</script>

View File

@@ -10,6 +10,7 @@
@input="updateValues(index, $event)"
@delete="removeItem(row)"
:disabled="disabled"
:headerPlaceholder="headerPlaceholder"
/>
</draggable>
<button @click="addNew" class="add-new" v-if="showAddNew">
@@ -40,7 +41,7 @@ export default defineComponent({
},
template: {
type: String,
default: null
default: null,
},
addLabel: {
type: String,
@@ -54,14 +55,18 @@ export default defineComponent({
type: Boolean,
default: false,
},
headerPlaceholder: {
type: String,
default: i18n.t('empty_item'),
},
},
setup(props, { emit }) {
const selection = ref<number[]>([]);
const _template = computed(() => {
if(props.template === null) return props.fields.length > 0 ? `{{${ props.fields[0].field}}}` : ''
return props.template
})
if (props.template === null) return props.fields.length > 0 ? `{{${props.fields[0].field}}}` : '';
return props.template;
});
const showAddNew = computed(() => {
if (props.disabled) return false;

View File

@@ -424,6 +424,7 @@
"modified": "Modified",
"checksum": "Checksum",
"owner": "Owner",
"edited_by": "Edited by",
"folder": "Folder",
"set_to_now": "Set to Now",
@@ -977,6 +978,8 @@
"collection_removed": "Collection Removed",
"collection_updated": "Collection Updated",
"collections_and_fields": "Collection & Fields",
"singleton": "Singleton",
"singleton_label": "Treat as single object",
"fields": {
"directus_activity": {

View File

@@ -1,19 +1,34 @@
<template>
<v-list large>
<template v-if="customNavItems && customNavItems.length > 0">
<v-detail
:active="group.accordion === 'always_open' || undefined"
:disabled="group.accordion === 'always_open'"
:start-open="group.accordion === 'start_open'"
:label="group.name"
:key="group.name"
v-for="group in customNavItems"
>
<v-list-item :exact="exact" v-for="navItem in group.items" :key="navItem.to" :to="navItem.to">
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
<v-list-item-content>{{ navItem.name }}</v-list-item-content>
</v-list-item>
</v-detail>
<template v-for="(group, index) in customNavItems">
<template
v-if="
(group.name === undefined || group.name === null) &&
group.accordion === 'always_open' &&
index === 0
"
>
<v-list-item :exact="exact" v-for="navItem in group.items" :key="navItem.to" :to="navItem.to">
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
<v-list-item-content>{{ navItem.name }}</v-list-item-content>
</v-list-item>
</template>
<template v-else>
<v-detail
:active="group.accordion === 'always_open' || undefined"
:disabled="group.accordion === 'always_open'"
:start-open="group.accordion === 'start_open'"
:label="group.name || null"
:key="group.name"
>
<v-list-item :exact="exact" v-for="navItem in group.items" :key="navItem.to" :to="navItem.to">
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
<v-list-item-content>{{ navItem.name }}</v-list-item-content>
</v-list-item>
</v-detail>
</template>
</template>
</template>
<v-list-item v-else :exact="exact" v-for="navItem in navItems" :key="navItem.to" :to="navItem.to">

View File

@@ -1,21 +1,16 @@
<template>
<v-divider v-if="section.divider" />
<v-list-group v-else-if="section.children" :dense="dense">
<v-list-group v-else-if="section.children" :dense="dense" :multiple="false" :value="section.to">
<template #activator>
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
<v-list-item-content>
<v-list-item-text>{{ section.name }}</v-list-item-text>
</v-list-item-content>
</template>
<navigation-list-item
v-for="(childSection, index) in section.children"
:key="index"
:section="childSection"
dense
/>
<navigation-list-item v-for="(child, index) in section.children" :key="index" :section="child" dense />
</v-list-group>
<v-list-item v-else :to="`/docs${section.to}`" :dense="dense">
<v-list-item v-else :to="`/docs${section.to}`" :dense="dense" :value="section.to">
<v-list-item-icon v-if="section.icon !== undefined"><v-icon :name="section.icon" /></v-list-item-icon>
<v-list-item-content>
<v-list-item-text>{{ section.name }}</v-list-item-text>
@@ -24,7 +19,7 @@
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Link, Group } from '@directus/docs';
export default defineComponent({

View File

@@ -1,18 +1,65 @@
<template>
<v-list large>
<v-list large :multiple="false" v-model="selection" :mandatory="false">
<navigation-item v-for="item in navSections" :key="item.name" :section="item" />
</v-list>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { defineComponent, PropType, computed, watch, ref } from '@vue/composition-api';
import NavigationItem from './navigation-item.vue';
import { nav } from '@directus/docs';
function spreadPath(path: string) {
const sections = path.substr(1).split('/');
if (sections.length === 0) return [];
const paths: string[] = ['/' + sections[0]];
for (let i = 1; i < sections.length; i++) {
paths.push(paths[i - 1] + '/' + sections[i]);
}
return paths;
}
export default defineComponent({
components: { NavigationItem },
setup() {
return { navSections: nav.app };
props: {
path: {
type: String,
default: '/docs',
},
},
setup(props) {
const _selection = ref<string[] | null>(null);
watch(
() => props.path,
(newPath) => {
if (newPath === null) return;
_selection.value = spreadPath(newPath.replace('/docs', ''));
}
);
const selection = computed({
get() {
if (_selection.value === null && props.path !== null)
_selection.value = spreadPath(props.path.replace('/docs', ''));
return _selection.value || [];
},
set(newSelection: string[]) {
if (newSelection.length === 0) {
_selection.value = [];
} else {
if (_selection.value && _selection.value.includes(newSelection[0])) {
_selection.value = _selection.value.filter((s) => s !== newSelection[0]);
} else {
_selection.value = spreadPath(newSelection[0]);
}
}
},
});
return { navSections: nav.app, selection };
},
});
</script>

View File

@@ -31,19 +31,19 @@ export default defineModule(({ i18n }) => {
for (const doc of directory.children) {
if (doc.type === 'file') {
routes.push({
path: '/' + doc.path.replace('.md', ''),
path: '/' + doc.path.replace('.md', '').replaceAll('\\', '/'),
component: StaticDocs,
});
} else if (doc.type === 'directory') {
routes.push({
path: '/' + doc.path,
redirect: '/' + doc.children![0].path.replace('.md', ''),
});
if (doc.path && doc.children && doc.children.length > 0)
routes.push({
path: '/' + doc.path.replaceAll('\\', '/'),
redirect: '/' + doc.children![0].path.replace('.md', '').replaceAll('\\', '/'),
});
routes.push(...parseRoutes(doc));
}
}
return routes;
}
});

View File

@@ -1,7 +1,7 @@
<template>
<private-view :title="$t('page_not_found')">
<template #navigation>
<docs-navigation />
<docs-navigation :path="path" />
</template>
<div class="not-found">
@@ -13,12 +13,25 @@
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, ref } from '@vue/composition-api';
import DocsNavigation from '../components/navigation.vue';
export default defineComponent({
name: 'NotFound',
components: { DocsNavigation },
async beforeRouteEnter(to, from, next) {
next((vm: any) => {
vm.path = to.path;
});
},
async beforeRouteUpdate(to, from, next) {
this.path = to.path;
next();
},
setup() {
const path = ref<string | null>(null);
return { path };
},
});
</script>

View File

@@ -9,7 +9,7 @@
</template>
<template #navigation>
<docs-navigation />
<docs-navigation :path="path" />
</template>
<div class="docs-content selectable">
@@ -55,16 +55,18 @@ export default defineComponent({
async beforeRouteEnter(to, from, next) {
const md = await getMarkdownForPath(to.path);
next((vm) => {
(vm as any).markdown = md;
next((vm: any) => {
vm.markdown = md;
vm.path = to.path;
});
},
async beforeRouteUpdate(to, from, next) {
this.markdown = await getMarkdownForPath(to.path);
this.path = to.path;
next();
},
setup() {
const path = ref<string | null>(null);
const markdown = ref('');
const view = ref<Vue>();
@@ -83,7 +85,7 @@ export default defineComponent({
view.value?.$data.contentEl?.scrollTo({ top: 0 });
});
return { markdown, title, markdownWithoutTitle, view, marked };
return { markdown, title, markdownWithoutTitle, view, marked, path };
},
});
</script>

View File

@@ -41,11 +41,25 @@
<dd>{{ file.checksum }}</dd>
</div>
<div v-if="user">
<div v-if="user_created">
<dt>{{ $t('owner') }}</dt>
<dd>
<user-popover :user="user.id">
<router-link :to="user.link">{{ user.name }}</router-link>
<user-popover :user="user_created.id">
<router-link :to="user_created.link">{{ user_created.name }}</router-link>
</user-popover>
</dd>
</div>
<div v-if="modificationDate">
<dt>{{ $t('modified') }}</dt>
<dd>{{ modificationDate }}</dd>
</div>
<div v-if="user_modified">
<dt>{{ $t('edited_by') }}</dt>
<dd>
<user-popover :user="user_modified.id">
<router-link :to="user_modified.link">{{ user_modified.name }}</router-link>
</user-popover>
</dd>
</div>
@@ -123,14 +137,15 @@ export default defineComponent({
return bytes(props.file.filesize, { decimalPlaces: 2, unitSeparator: ' ' }); // { locale: i18n.locale.split('-')[0] }
});
const { creationDate } = useCreationDate();
const { user } = useUser();
const { creationDate, modificationDate } = useDates();
const { userCreated, userModified } = useUser();
const { folder } = useFolder();
return { readableMimeType, size, creationDate, user, folder, marked };
return { readableMimeType, size, creationDate, modificationDate, userCreated, userModified, folder, marked };
function useCreationDate() {
function useDates() {
const creationDate = ref<string | null>(null);
const modificationDate = ref<string | null>(null);
watch(
() => props.file,
@@ -141,11 +156,18 @@ export default defineComponent({
new Date(props.file.uploaded_on),
String(i18n.t('date-fns_date_short'))
);
if (props.file.modified_on) {
modificationDate.value = await localizedFormat(
new Date(props.file.modified_on),
String(i18n.t('date-fns_date_short'))
);
}
},
{ immediate: true }
);
return { creationDate };
return { creationDate, modificationDate };
}
function useUser() {
@@ -156,11 +178,12 @@ export default defineComponent({
};
const loading = ref(false);
const user = ref<User | null>(null);
const userCreated = ref<User | null>(null);
const userModified = ref<User | null>(null);
watch(() => props.file, fetchUser, { immediate: true });
return { user };
return { userCreated, userModified };
async function fetchUser() {
if (!props.file) return null;
@@ -177,11 +200,27 @@ export default defineComponent({
const { id, first_name, last_name, role } = response.data.data;
user.value = {
userCreated.value = {
id: props.file.uploaded_by,
name: first_name + ' ' + last_name,
link: `/users/${id}`,
};
if (props.file.modified_by) {
const response = await api.get(`/users/${props.file.modified_by}`, {
params: {
fields: ['id', 'first_name', 'last_name', 'role'],
},
});
const { id, first_name, last_name, role } = response.data.data;
userModified.value = {
id: props.file.modified_by,
name: first_name + ' ' + last_name,
link: `/users/${id}`,
};
}
} finally {
loading.value = false;
}

View File

@@ -274,6 +274,8 @@ export default defineComponent({
'checksum',
'uploaded_by',
'uploaded_on',
'modified_by',
'modified_on',
'duration',
'folder',
'charset',
@@ -287,9 +289,9 @@ export default defineComponent({
});
const to = computed(() => {
if(item.value && item.value?.folder) return `/files?folder=${item.value.folder}`
else return '/files'
})
if (item.value && item.value?.folder) return `/files?folder=${item.value.folder}`;
else return '/files';
});
const { formFields } = useFormFields(fieldsFiltered);
@@ -332,7 +334,7 @@ export default defineComponent({
selectedFolder,
fileSrc,
form,
to
to,
};
function changeCacheBuster() {

View File

@@ -178,6 +178,7 @@ export default defineComponent({
interface: 'one-to-many',
},
});
state.relations[0].one_field = state.relations[0].one_collection;
} else {
state.newFields = state.newFields.filter((field: any) => field.$type !== 'corresponding');
}

View File

@@ -26,19 +26,25 @@
<v-tabs-items v-model="currentTab">
<v-tab-item value="collection">
<h2 class="type-title">{{ $t('creating_collection_info') }}</h2>
<div class="type-label">
{{ $t('name') }}
<v-icon class="required" v-tooltip="$t('required')" name="star" sup />
</div>
<v-input
autofocus
class="monospace"
v-model="collectionName"
db-safe
:placeholder="$t('a_unique_table_name')"
/>
<v-divider />
<div class="grid">
<div>
<div class="type-label">
{{ $t('name') }}
<v-icon class="required" v-tooltip="$t('required')" name="star" sup />
</div>
<v-input
autofocus
class="monospace"
v-model="collectionName"
db-safe
:placeholder="$t('a_unique_table_name')"
/>
</div>
<div>
<div class="type-label">{{ $t('singleton') }}</div>
<v-checkbox block :label="$t('singleton_label')" v-model="singleton" />
</div>
<v-divider class="full" />
<div>
<div class="type-label">{{ $t('primary_key_field') }}</div>
<v-input
@@ -73,9 +79,14 @@
<v-tab-item value="system">
<h2 class="type-title">{{ $t('creating_collection_system') }}</h2>
<div class="grid system">
<div class="field" v-for="(info, field) in systemFields" :key="field">
<div v-for="(info, field) in systemFields" :key="field">
<div class="type-label">{{ $t(info.label) }}</div>
<v-input v-model="info.name" class="monospace" :class="{active: info.enabled}" @click.native="info.enabled = true">
<v-input
v-model="info.name"
class="monospace"
:class="{ active: info.enabled }"
@click.native="info.enabled = true"
>
<template #prepend>
<v-checkbox v-model="info.enabled" />
</template>
@@ -124,6 +135,7 @@ export default defineComponent({
const currentTab = ref(['collection']);
const collectionName = ref(null);
const singleton = ref(false);
const primaryKeyFieldName = ref('id');
const primaryKeyFieldType = ref<'auto_int' | 'uuid' | 'manual'>('auto_int');
@@ -184,6 +196,7 @@ export default defineComponent({
collectionName,
saveError,
saving,
singleton,
};
async function save() {
@@ -198,6 +211,7 @@ export default defineComponent({
archive_field: archiveField.value,
archive_value: archiveValue.value,
unarchive_value: unarchiveValue.value,
singleton: singleton.value,
},
});
@@ -412,22 +426,14 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.type-title {
margin-bottom: 48px;
}
.type-label {
margin-bottom: 12px;
}
.v-divider {
margin: 48px 0;
}
.grid {
display: grid;
grid-gap: 48px 36px;
grid-template-columns: repeat(2, 1fr);
@include form-grid;
}
.system {

View File

@@ -7,17 +7,21 @@ const dirTree = require('directory-tree');
const yaml = require('js-yaml');
async function build() {
console.log('Building docs...');
const start = Date.now();
const distPath = path.resolve(__dirname, './dist');
await rimraf(distPath);
const tree = dirTree('.', { extensions: /\.md/, exclude: /dist/ });
const tree = dirTree('.', { extensions: /\.md/, exclude: /(dist|node_modules)/ });
await fse.ensureDir(distPath);
await fse.writeJSON('./dist/index.json', tree);
await copyfiles(['./**/*.md', distPath]);
await copyfiles(['./**/*.md', distPath], { exclude: './node_modules/**/*.*' });
const yamlFiles = [];
const filesInRoot = await fse.readdir(__dirname);
@@ -35,6 +39,8 @@ async function build() {
yaml.safeLoad(yamlString)
);
}
console.log(`Built docs in ${Date.now() - start} ms`);
}
build();

1
docs/index.d.ts vendored
View File

@@ -21,6 +21,7 @@ export type Link = {
export type Group = {
name: string;
to: string;
children: (Group | Link | Divider)[];
icon?: string;
};

View File

@@ -1,5 +1,6 @@
- name: Getting Started
icon: play_arrow
to: "/getting-started"
children:
- name: Introduction
to: "/getting-started/introduction"
@@ -12,6 +13,7 @@
- name: Concepts
icon: school
to: "/concepts"
children:
- name: Platform Overview
to: "/concepts/platform-overview"
@@ -32,6 +34,7 @@
- name: Guides
icon: article
to: "/guides"
children:
- name: Collections
to: "/guides/collections"
@@ -52,33 +55,36 @@
- name: Extensions
to: "/guides/extensions"
children:
- name: Custom Displays
to: "/guides/extensions/creating-a-custom-display"
- name: Custom Interfaces
to: "/guides/extensions/creating-a-custom-interface"
- name: Custom Layouts
to: "/guides/extensions/creating-a-custom-layout"
- name: Custom Modules
to: "/guides/extensions/creating-a-custom-module"
- name: Custom API Endpoints
to: "/guides/extensions/creating-a-custom-api-endpoint"
- name: Custom API Hooks
to: "/guides/extensions/creating-a-custom-api-hook"
- name: Custom Email Templates
to: "/guides/extensions/creating-a-custom-email-template"
- name: Custom Storage Adapters
to: "/guides/extensions/creating-a-custom-storage-adapter"
- name: Displays
to: "/guides/extensions/displays"
- name: Interfaces
to: "/guides/extensions/interfaces"
- name: Layouts
to: "/guides/extensions/layouts"
- name: Modules
to: "/guides/extensions/modules"
- name: API Endpoints
to: "/guides/extensions/api-endpoints"
- name: API Hooks
to: "/guides/extensions/api-hooks"
- name: Email Templates
to: "/guides/extensions/email-templates"
- name: Storage Adapters
to: "/guides/extensions/storage-adapters"
- name: Accessing Data
to: "/guides/extensions/accessing-data"
- name: Reference
icon: code
to: "/reference"
children:
- name: Environment Variables
to: "/reference/environment-variables"
- name: Command Line Interface
to: "/reference/command-line-interface"
- name: Environment Variables
to: "/reference/environment-variables"
- name: Error Codes
to: "/reference/error-codes"
- name: Item Rules
to: "/reference/item-rules"
- name: Filter Rules
to: "/reference/filter-rules"
- name: Item Objects
to: "/reference/item-objects"

1079
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,17 @@
"main": "index.js",
"scripts": {
"build": "node build.js",
"prepublish": "npm run build"
"prepublish": "npm run build",
"dev": "npm-watch build"
},
"watch": {
"build": {
"patterns": ["."],
"ignore": "dist",
"extensions": "md,yaml",
"silent": true,
"quiet": true
}
},
"files": [
"dist"
@@ -14,5 +24,8 @@
"keywords": [],
"author": "",
"license": "ISC",
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec"
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
"devDependencies": {
"npm-watch": "^0.7.0"
}
}

5
packages/spec/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import { OpenAPIObject } from 'openapi3-ts';
declare const DirectusSpec: OpenAPIObject;
export default DirectusSpec;

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,23 @@
{
"name": "@directus/specs",
"version": "9.0.0-beta.10",
"description": "Specification of the Directus Api",
"description": "OpenAPI Specification of the Directus API",
"main": "index.js",
"scripts": {
"ui:watch": "swagger-ui-watcher specs/openapi.yaml",
"validate": "swagger-cli validate specs/openapi.yaml",
"build": "swagger-cli bundle specs/openapi.yaml -o dist/openapi.json",
"build:deref": "swagger-cli bundle specs/openapi.yaml -o dist/openapi-deref.json --dereference",
"prepublishOnly": "npm run build && npm run build:deref"
"prepublishOnly": "npm run build && npm run build:deref",
"dev": "npm-watch build"
},
"watch": {
"build": {
"patterns": ["specs"],
"extensions": "yaml",
"quiet": true,
"silent": true
}
},
"repository": {
"type": "git",
@@ -26,5 +35,9 @@
"README.md",
"index.js"
],
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec"
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
"devDependencies": {
"npm-watch": "^0.7.0",
"swagger-cli": "^4.0.4"
}
}

View File

@@ -1,4 +1,5 @@
type: object
x-collection: directus_activity
properties:
id:
description: Unique identifier for the object.
@@ -8,39 +9,44 @@ properties:
description: Action that was performed.
example: update
type: string
enum: [authenticate, comment, upload, create, update, delete, soft-delete, revert, invalid-credentials]
enum:
- create
- update
- delete
- authenticate
user:
description: Unique identifier of the user account who caused this action.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
type: string
nullable: true # States the SQL structure
description: The user who performed this action.
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Users"
nullable: true
timestamp:
description: When the action happened.
example: '2019-12-05 22:52:09'
example: "2019-12-05T22:52:09Z"
type: string
format: date-time
ip:
description: The IP address of the user at the time the action took place.
example: 160.72.72.58
example: 127.0.0.1
oneOf:
- type: string
format: ipv4
- type: string
enum: [localhost]
user_agent:
description: User agent string of the browser the user used when the action took place.
example: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML,like Gecko) Chrome/78.0.3904.108 Safari/537.36
type: string
collection:
description: Collection identifier in which the item resides.
example: movies
type: string
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Collections"
item:
description: Unique identifier for the item the action applied to. This is always a string, even for integer primary keys.
example: '328'
example: "328"
type: string
comment:
description: User comment. This will store the comments that show up in the right
description:
User comment. This will store the comments that show up in the right
sidebar of the item edit page in the admin app.
example: null
type: string

View File

@@ -1,43 +1,86 @@
type: object
x-collection: directus_collections
properties:
collection:
description: The collection name.
description: The collection key.
example: customers
type: string
meta:
description: Metadata of the collection.
type: object
example: null
nullable: true
properties:
collection:
description: The collection name again!
description: The collection key.
example: customers
type: string
hidden:
type: boolean
singleton:
type: boolean
icon:
description: Name of a Google Material Design Icon that's assigned to this collection.
type: string
example: people
nullable: true
note:
description: A note describing the collection.
type: string
nullable: true
translation:
type: string
example: null
nullable: true
display_template:
description: Text representation of how items from this collection are shown across the system.
type: string
example: null
nullable: true
hidden:
description: Whether or not the collection is hidden from the navigation in the admin app.
type: boolean
example: false
singleton:
description: Whether or not the collection is treated as a single object.
type: boolean
example: false
translations:
description: Key value pairs of how to show this collection's name in different languages in the admin app.
type: string
example: null
nullable: true
archive_field:
description: What field holds the archive value.
type: string
example: null
nullable: true
archive_app_filter:
description: What value to use for "archived" items.
type: string
example: null
nullable: true
archive_value:
description: What value to use to "unarchive" items.
type: string
example: null
nullable: true
unarchive_value:
description: Whether or not to show the "archived" filter.
type: string
example: null
nullable: true
sort_field:
description: The sort field in the collection.
type: string
example: null
nullable: true
schema:
type: object
properties:
name:
description: The collection key.
type: string
example: customers
schema:
description: Database schema (pg only).
example: public
type: string
comment:
description: Comment as saved in the database.
type: string
collation:
type: string
engine:
type: string
example: null
nullable: true

View File

@@ -1,10 +1,6 @@
type: object
x-collection: directus_fields
properties:
id:
description: Unique identifier for the field in the `directus_fields` collection.
example: 167
type: integer
nullable: true
collection:
description: Unique name of the collection this field is in.
example: about_us
@@ -13,102 +9,159 @@ properties:
description: Unique name of the field. Field name is unique within the collection.
example: id
type: string
auto_increment:
description: If the value in this field is auto incremented. Only applies to integer
type fields.
example: true
type: boolean
datatype:
description: SQL datatype of the column that corresponds to this field.
example: INT
type: string
nullable: true
group:
description: What field group this field is part of.
example: null
type: integer
nullable: true
hidden_browse:
description: If this field should be hidden from the item browse (listing) page.
example: true
type: boolean
hidden_detail:
description: If this field should be hidden from the item detail (edit) page.
example: true
type: boolean
interface:
description: What interface is used in the admin app to edit the value for this
field.
example: primary-key
type: string
nullable: true
length:
description: Length of the field. Will be used in SQL to set the `length` property
of the colummn.
example: '10'
type: string
nullable: true
locked:
description: If the field can be altered by the end user. Directus system fields
have this value set to `true`.
example: true
type: boolean
note:
description: A user provided note for the field. Will be rendered alongside the
interface on the edit page.
example: ''
type: string
nullable: true
options:
description: Options for the interface that's used. This format is based on the
individual interface.
example: null
type: object
nullable: true
primary_key:
description: If this field is the primary key of the collection.
example: true
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
example: false
type: boolean
required:
description: If this field requires a value.
example: true
type: boolean
signed:
description: If the value is signed or not. Only applies to integer type fields.
example: false
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
example: 1
type: integer
nullable: true
translation:
description: 'Key value pair of `<language>: <translation>` that allows the user
to change the displayed name of the field in the admin app.'
example: null
type: object
nullable: true
type:
description: Directus specific data type. Used to cast values in the API.
example: integer
type: string
unique:
description: If the value of this field should be unique within the collection.
example: false
type: boolean
validation:
description: User provided regex that will be used in the API to validate incoming
values. It uses the PHP flavor of RegEX.
example: null
type: string
schema:
description: The schema info.
type: object
properties:
name:
description: The name of the field.
example: title
type: string
table:
description: The collection of the field.
example: posts
type: string
type:
description: The datatype of the field.
example: string
type: string
default_value:
description: The default value of the field.
example: null
type: string
nullable: true
max_length:
description: The max length of the field.
example: null
type: integer
nullable: true
is_nullable:
description: If the field is nullable.
example: false
type: boolean
is_primary_key:
description: If the field is primary key.
example: false
type: boolean
has_auto_increment:
description: If the field has auto increment.
example: false
type: boolean
foreign_key_column:
description: Related column from the foreign key constraint.
example: null
type: string
nullable: true
foreign_key_table:
description: Related table from the foreign key constraint.
example: null
type: string
nullable: true
comment:
description: Comment as saved in the database.
example: null
type: string
nullable: true
schema:
description: Database schema (pg only).
example: public
type: string
foreign_key_schema:
description: Related schema from the foreign key constraint (pg only).
example: null
type: string
nullable: true
meta:
description: The meta info.
type: object
nullable: true
width:
description: Width of the field on the edit form.
example: null
type: string
nullable: true
enum: [half, half-left, half-right, full, fill, null]
properties:
id:
description: Unique identifier for the field in the `directus_fields` collection.
example: 3
type: integer
collection:
description: Unique name of the collection this field is in.
example: posts
type: string
field:
description: Unique name of the field. Field name is unique within the collection.
example: title
type: string
special:
description: Transformation flags for field
example: null
type: array
items:
type: string
nullable: true
interface:
description:
What interface is used in the admin app to edit the value for this field.
example: primary-key
type: string
nullable: true
options:
description:
Options for the interface that's used. This format is based on the individual interface.
example: null
type: object
nullable: true
display:
description: What display is used in the admin app to display the value for this field.
example: null
type: string
nullable: true
display_options:
description: Options for the display that's used. This format is based on the individual display.
example: null
type: object
nullable: true
locked:
description:
If the field can be altered by the end user. Most Directus system fields
have this value set to `true`.
example: true
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
example: false
type: boolean
hidden:
description: If this field should be hidden.
example: true
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
example: 1
type: integer
nullable: true
width:
description: Width of the field on the edit form.
example: null
type: string
nullable: true
enum: [half, half-left, half-right, full, fill, null]
group:
description: What field group this field is part of.
example: null
type: integer
nullable: true
translations:
description:
"Key value pair of `<language>: <translation>` that allows the user
to change the displayed name of the field in the admin app."
example: null
type: object
nullable: true
note:
description:
A user provided note for the field. Will be rendered alongside the
interface on the edit page.
example: ""
type: string
nullable: true

View File

@@ -1,60 +1,72 @@
type: object
x-collection: directus_files
properties:
id:
description: Unique identifier for the file.
example: 8cbb43fe-4cdf-4991-8352-c461779cec02
type: string
storage:
description:
Where the file is stored. Either `local` for the local filesystem
or the name of the storage adapter (for example `s3`).
example: local
type: string
filename_disk:
description:
Name of the file on disk. By default, Directus uses a random hash
for the filename.
example: a88c3b72-ac58-5436-a4ec-b2858531333a.jpg
type: string
filename_download:
description: How you want to the file to be named when it's being downloaded.
example: avatar.jpg
type: string
title:
description:
Title for the file. Is extracted from the filename on upload, but
can be edited by the user.
example: User Avatar
type: string
type:
description: MIME type of the file.
example: image/jpeg
type: string
folder:
description: Virtual folder where this file resides in.
example: null
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Folders"
nullable: true
uploaded_by:
description: Who uploaded the file.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Users"
uploaded_on:
description: When the file was uploaded.
example: "2019-12-03T00:10:15+00:00"
type: string
format: date-time
charset:
description: Character set of the file.
example: binary
type: string
nullable: true # Should not be null?
checksum:
description: Represents the sum of the correct digits of the file, can be used
to detect errors in and duplicates of the file later.
example: d41d8cd98f00b204e9800998ecf8427e
type: string
data:
example:
embed: null
full_url: 'https://demo.directus.io/uploads/thumper/originals/a88c3b72-ac58-5436-a4ec-b2858531333a.jpg'
thumbnails:
dimension: 64x64
height: 64
relative_url: '/thumper/assets/pnw7s9lqy68048g0?key=directus-small-crop'
url: 'https://demo.directus.io/thumper/assets/pnw7s9lqy68048g0?key=directus-small-crop'
width: 64
url: '/uploads/thumper/originals/a88c3b72-ac58-5436-a4ec-b2858531333a.jpg'
properties:
full_url:
description: Full URL to the original file.
type: string
thumbnails:
description: List of all available asset sizes with links.
type: array
nullable: true
items:
type: object
properties:
dimension:
description: Width x height of the thumbnail.
type: string
height:
description: Height of the thumbnail in pixels.
type: integer
relative_url:
description: Relative URL to the thumbnail.
type: string
url:
description: Full URL to the thumbnail.
type: string
width:
description: Width of the thumbnail in pixels.
type: integer
url:
description: Relative URL to the original file.
type: string
type: object
description:
description: Description for the file.
example: ''
type: string
nullable: true
filesize:
description: Size of the file in bytes.
example: 137862
type: integer
width:
description: Width of the file in pixels. Only applies to images.
example: 800
type: integer
nullable: true
height:
description: Height of the file in pixels. Only applies to images.
example: 838
type: integer
nullable: true
duration:
description: Duration of the file in seconds. Only applies to audio and video.
@@ -66,82 +78,25 @@ properties:
example: null
type: string
nullable: true
filename_disk:
description: Name of the file on disk. By default, Directus uses a random hash
for the filename.
example: a88c3b72-ac58-5436-a4ec-b2858531333a.jpg
description:
description: Description for the file.
type: string
filename_download:
description: How you want to the file to be named when it's being downloaded.
example: avatar.jpg
type: string
filesize:
description: Size of the file in bytes.
example: 137862
type: integer
folder:
description: Virtual folder where this file resides in.
example: null
$ref: '../openapi.yaml#/components/schemas/Folder'
nullable: true
height:
description: Height of the file in pixels. Only applies to images.
example: 838
type: integer
nullable: true
id:
description: Unique identifier for the file.
example: 8cbb43fe-4cdf-4991-8352-c461779cec02
type: string
location:
description: Where the file was created. Is automatically populated based on EXIF
description:
Where the file was created. Is automatically populated based on EXIF
data for images.
type: string
nullable: true
metadata:
description: User provided miscellaneous key value pairs that serve as additional
metadata for the file.
example: null
type: object
nullable: true
private_hash:
description: Random hash used to access the file privately. This can be rotated
to prevent unauthorized access to the file.
example: pnw7s9lqy68048g0
type: string
storage:
description: Where the file is stored. Either `local` for the local filesystem
or the name of the storage adapter (for example `s3`).
example: local
type: string
tags:
description: Tags for the file. Is automatically populated based on EXIF data
for images.
description:
Tags for the file. Is automatically populated based on EXIF data for images.
type: array
nullable: true
items:
type: string
title:
description: Title for the file. Is extracted from the filename on upload, but
can be edited by the user.
example: User Avatar
type: string
type:
description: MIME type of the file.
example: image/jpeg
type: string
uploaded_by:
description: Who uploaded the file.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
type: string
# $ref: '../openapi.yaml#/components/schemas/User'
uploaded_on:
description: When the file was uploaded.
example: '2019-12-03T00:10:15+00:00'
type: string
format: date-time
width:
description: Width of the file in pixels. Only applies to images.
example: 800
type: integer
nullable: true
metadata:
description:
IPTC, EXIF, and ICC metadata extracted from file
type: object
nullable: true

View File

@@ -1,4 +1,5 @@
type: object
x-collection: directus_files
properties:
id:
description: Unique identifier for the folder.
@@ -11,5 +12,7 @@ properties:
parent:
description: Unique identifier of the parent folder. This allows for nested folders.
example: null
type: string
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Folders"
nullable: true

View File

@@ -1,2 +1,2 @@
type: object
properties: {}
properties: {}

View File

@@ -1,70 +1,47 @@
type: object
x-collection: directus_permissions
properties:
collection:
description: What collection this permission applies to.
example: customers
type: string
comment:
description: If the user can post comments.
example: update
type: string
enum: [none, create, update, full]
create:
description: If the user can create items.
example: full
type: string
enum: [none, full]
delete:
description: If the user can update items.
example: none
type: string
enum: [none, mine, role, full]
explain:
description: If the user is required to leave a comment explaining what was changed.
example: none
type: string
enum: [none, create, update, always]
id:
description: Unique identifier for the permission.
example: 1
type: integer
read:
description: If the user can read items.
example: mine
type: string
enum: [none, mine, role, full]
read_field_blacklist:
description: Explicitly denies read access for specific fields.
example: []
type: array
items:
type: string
role:
description: Unique identifier of the role this permission applies to.
example: 2f24211d-d928-469a-aea3-3c8f53d4e426
type: string
nullable: true # Should this be nullable?
status:
description: What status this permission applies to.
example: null
type: string
nullable: true
status_blacklist:
description: Explicitly denies specific statuses to be used.
example: []
oneOf:
- type: array
nullable: true
items:
type: string
update:
description: If the user can update items.
example: none
collection:
description: What collection this permission applies to.
example: customers
type: string
enum: [none, mine, role, full]
write_field_blacklist:
description: Explicitly denies write access for specific fields.
example: []
action:
description: What action this permission applies to.
example: create
type: string
enum:
- create
- read
- update
- delete
permissions:
description: JSON structure containing the permissions checks for this permission.
type: object
nullable: true
validation:
description: JSON structure containing the validation checks for this permission.
type: object
nullable: true
presets:
description: JSON structure containing the preset value for created/updated items.
type: object
nullable: true
fields:
description: CSV of fields that the user is allowed to interact with.
type: array
items:
type: string
type: string
nullable: true
limit:
description: Maximum amount of items the user can interact with at a time.
type: number
nullable: true

View File

@@ -1,73 +1,73 @@
type: object
x-collection: directus_presets
properties:
id:
description: Unique identifier for this single collection preset.
example: 155
type: integer
bookmark:
description:
Name for the bookmark. If this is set, the preset will be considered a bookmark.
nullable: true
type: string
user:
description:
The unique identifier of the user to whom this collection preset applies.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
nullable: true
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Users"
role:
description:
The unique identifier of a role in the platform. If `user` is null,
this will be used to apply the collection preset or bookmark for all users in
the role.
example: 50419801-0f30-8644-2b3c-9bc2d980d0a0
nullable: true
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Roles"
collection:
description: What collection this collection preset is used for.
example: articles
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Collections"
search:
description: Search query.
type: string
nullable: true
filters:
description: The filters that the user applied.
example:
field: title
operator: contains
value: Hello
- key: 7RwVrquB5dPmfbrI1rcWy
field: title
operator: contains
value: Hello
type: array
nullable: true
items:
type: object
id:
description: Unique identifier for this single collection preset.
example: '155'
type: integer
role:
description: The unique identifier of a role in the platform. If `user` is null,
this will be used to apply the collection preset or bookmark for all users in
the role.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
nullable: true
layout:
description: Key of the layout that is used.
type: string
search_query:
description: What the user searched for in search/filter in the header bar.
example: null
type: string
nullable: true
title:
description: Name for the bookmark. If this is set, the collection preset will
be considered to be a bookmark.
example: null
type: string
nullable: true
translation:
description: Key value pair of language-translation. Can be used to translate
the bookmark title in multiple languages.
example: null
type: object
nullable: true
user:
description: The unique identifier of the user to whom this collection preset
applies.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
nullable: true
type: string
view_options:
description: Options of the views. The properties in here are controlled by the
layout.
example:
timeline:
color: action
content: excerpt
date: published_on
title: '{{ title }} ({{ author.first_name }} {{ author.last_name }})'
type: object
nullable: true
view_query:
description: View query that's saved per view type. Controls what data is fetched
layout_query:
description:
Layout query that's saved per layout type. Controls what data is fetched
on load. These follow the same format as the JS SDK parameters.
example:
timeline:
cards:
sort: -published_on
type: object
nullable: true
view_type:
description: Name of the view type that is used.
example: timeline
type: string
layout_options:
description:
Options of the views. The properties in here are controlled by the layout.
example:
cards:
icon: account_circle
title: "{{ first_name }} {{ last_name }}"
subtitle: "{{ title }}"
size: 3
nullable: true

View File

@@ -1,5 +1,10 @@
type: object
x-collection: directus_relations
properties:
id:
description: Unique identifier for the relation.
example: 1
type: integer
many_collection:
description: Collection that has the field that holds the foreign key.
example: directus_activity
@@ -9,7 +14,7 @@ properties:
example: user
type: string
many_primary:
description: The primary field.
description: The primary key field of the current collection.
example: id
type: string
one_collection:
@@ -22,16 +27,12 @@ properties:
type: string
nullable: true
one_primary:
description: The primary field.
description: The primary key field of the related collection.
example: id
type: string
id:
description: Unique identifier for the relation.
example: 1
type: integer
junction_field:
description: Field on the junction table that holds the primary key of the related
collection.
description:
Field on the junction table that holds the many field of the related relation.
example: null
type: string
nullable: true

View File

@@ -1,12 +1,25 @@
type: object
x-collection: directus_revisions
properties:
activity:
description: Unique identifier for the [activity](/api/activity) record.
example: 2
id:
description: Unique identifier for the revision.
example: 1
type: integer
activity:
description: Unique identifier for the activity record.
example: 2
oneOf:
- type: integer
- $ref: "../openapi.yaml#/components/schemas/Activity"
collection:
description: Collection of the updated item.
example: articles
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Collections"
item:
description: Primary key of updated item.
example: "168"
type: string
data:
description: Copy of item state at time of update.
@@ -14,38 +27,18 @@ properties:
author: 1
body: This is my first post
featured_image: 15
id: '168'
id: "168"
title: Hello, World!
type: object
nullable: true # Should this be nullable?
nullable: true
delta:
description: Changes between the previous and the current revision.
example:
title: Hello, World!
oneOf:
- type: object
id:
description: Unique identifier for the revision.
example: 1
type: object
parent:
description:
If the current item was updated relationally, this is the id of the parent revision record
example: null
type: integer
item:
description: Primary key of updated item.
example: '168'
type: string
parent_changed:
description: If the current item was updated relationally, this shows if the parent
item was updated as well.
example: false
type: boolean
parent_collection:
description: If the current item was updated relationally, this is the collection
of the parent item.
example: null
type: string
nullable: true
parent_item:
description: If the current item was updated relationally, this is the unique
identifier of the parent item.
example: null
type: string
nullable: true

View File

@@ -1,43 +1,52 @@
type: object
x-collection: directus_roles
properties:
collection_listing:
description: Custom override for the admin app collection navigation.
example: null
type: object
nullable: true
id:
description: Unique identifier for the role.
example: 2f24211d-d928-469a-aea3-3c8f53d4e426
type: string
name:
description: Name of the role.
example: Administrator
type: string
icon:
description: The role's icon.
example: verified_user
type: string
description:
description: Description of the role.
example: Admins have access to all managed data within the system by default
type: string
nullable: true
enforce_tfa:
description: Whether or not this role enforces the use of 2FA.
example: false
type: boolean
external_id:
description: ID used with external services in SCIM.
example: null
type: string
nullable: true
id:
description: Unique identifier for the role.
example: 2f24211d-d928-469a-aea3-3c8f53d4e426
type: string
ip_whitelist:
description: Array of IP addresses that are allowed to connect to the API as a
ip_access:
description:
Array of IP addresses that are allowed to connect to the API as a
user of this role.
example: []
type: array
items:
type: string
module_listing:
enforce_tfa:
description: Whether or not this role enforces the use of 2FA.
example: false
type: boolean
module_list:
description: Custom override for the admin app module bar navigation.
example: null
type: array
items:
type: object
nullable: true
name:
description: Name of the role.
example: Administrator
type: string
collection_list:
description: Custom override for the admin app collection navigation.
example: null
type: object
nullable: true
admin_access:
description: Admin role. If true, skips all permission checks.
example: false
type: boolean
app_access:
description: The users in the role are allowed to use the app.
example: true
type: boolean

View File

@@ -1,7 +1,84 @@
type: object
x-collection: directus_settings
properties:
id:
description: Unique identifier for the setting.
example: 1
type: integer
additionalProperties: true
example: 1
project_name:
description: The name of the project.
type: string
example: Directus
project_url:
description: The url of the project.
type: string
example: null
nullable: true
project_color:
description: The brand color of the project.
type: string
example: null
nullable: true
project_logo:
description: The logo of the project.
type: string
example: null
nullable: true
public_foreground:
description: The foreground of the project.
type: string
example: null
nullable: true
public_background:
description: The background of the project.
type: string
example: null
nullable: true
public_note:
description: Note rendered on the public pages of the app.
type: string
example: null
nullable: true
auth_login_attempts:
description: Allowed authentication login attempts before the user's status is set to blocked.
type: integer
example: 25
auth_password_policy:
description: Authentication password policy.
type: string
nullable: true
storage_asset_transform:
description: What transformations are allowed in the assets endpoint.
type: string
enum:
- all
- none
- presets
example: "all"
nullable: true
storage_asset_presets:
description: Array of allowed
type: array
items:
type: object
properties:
key:
description: Key for the asset. Used in the assets endpoint.
type: string
fit:
description: Whether to crop the thumbnail to match the size, or maintain the aspect ratio.
type: string
enum:
- cover
- contain
width:
description: Width of the thumbnail.
type: integer
height:
description: Height of the thumbnail.
type: integer
quality:
description: Quality of the compression used.
type: integer
example: null
nullable: true

View File

@@ -1,76 +1,92 @@
type: object
x-collection: directus_users
properties:
tfa_secret:
description: The 2FA secret string that's used to generate one time passwords.
example: null
id:
description: Unique identifier for the user.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
type: string
first_name:
description: First name of the user.
example: Admin
type: string
last_name:
description: Last name of the user.
example: User
type: string
nullable: true
avatar:
description: The user's avatar.
example: null
oneOf:
- type: integer
nullable: true
- type: string
- $ref: '../openapi.yaml#/components/schemas/File'
nullable: true
email:
description: Unique email address for the user.
example: admin@example.com
type: string
format: email
external_id:
description: ID used for SCIM.
password:
description: Password of the user.
type: string
location:
description: The user's location.
example: null
type: string
nullable: true
first_name:
description: First name of the user.
example: Admin
type: string
id:
description: Unique identifier for the user.
example: 63716273-0f29-4648-8a2a-2af2948f6f78
type: string
last_login:
description: When this user logged in last.
example: '2020-05-31 14:32:37'
title:
description: The user's title.
example: null
type: string
nullable: true
format: date-time
last_name:
description: First name of the user.
example: User
type: string
last_page:
description: Last page that the user was on.
example: /my-project/settings/collections/a
description:
description: The user's description.
example: null
type: string
nullable: true
tags:
description: The user's tags.
example: null
type: array
nullable: true
items:
type: string
avatar:
description: The user's avatar.
example: null
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Files"
nullable: true
language:
description: The user's language used in Directus.
example: en-US
type: string
role:
description: Unique identifier of the role of this user.
example: 2f24211d-d928-469a-aea3-3c8f53d4e426
type: string
status:
description: Status of the user.
example: active
type: string
enum: [active, invited, draft, suspended, deleted]
theme:
description: What theme the user is using.
example: auto
type: string
enum: [light, dark, auto]
timezone:
description: The user's timezone.
example: America/New_York
type: string
title:
description: The user's title.
tfa_secret:
description: The 2FA secret string that's used to generate one time passwords.
example: null
type: string
nullable: true
nullable: true
status:
description: Status of the user.
example: active
type: string
enum: [active, invited, draft, suspended, deleted]
role:
description: Unique identifier of the role of this user.
example: 2f24211d-d928-469a-aea3-3c8f53d4e426
oneOf:
- type: string
- $ref: "../openapi.yaml#/components/schemas/Roles"
token:
description: Static token for the user.
type: string
nullable: true
last_acces:
description: When this user used the API last.
example: "2020-05-31T14:32:37Z"
type: string
nullable: true
format: date-time
last_page:
description: Last page that the user was on.
example: /my-project/settings/collections/a
type: string
nullable: true

View File

@@ -1,6 +1,42 @@
type: object
x-collection: directus_webhooks
properties:
id:
description: The index of the webhook.
type: integer
example: 1
example: 1
name:
description: The name of the webhook.
type: string
example: create articles
method:
description: Method used in the webhook.
type: string
example: POST
url:
description: The url of the webhook.
type: string
example: null
nullable: true
status:
description: The status of the webhook.
type: string
example: inactive
data:
description: If yes, send the content of what was done
type: boolean
example: true
actions:
description: The actions that triggers this webhook.
type: array
items:
type: string
example: null
nullable: true
collections:
description: The collections that triggers this webhook.
type: array
items:
type: string
example: null
nullable: true

View File

@@ -1,303 +1,297 @@
openapi: 3.0.1
info:
title: Directus SDK
title: Directus API
description: Template for generating any kind of SDK.
contact:
email: contact@directus.io
license:
name: GPL-3.0
url: 'https://www.gnu.org/licenses/gpl-3.0.de.html'
version: 1.0.0
url: "https://www.gnu.org/licenses/gpl-3.0.html"
version: 9.0.0
externalDocs:
description: Directus Docs
url: 'https://docs.directus.io'
servers:
- url: 'https://demo.directus.io/'
- url: '/'
url: "https://docs.directus.io"
tags:
- name: Activity
description: All events that happen within Directus are tracked and stored in the activities collection. This gives you full accountability over everything that happens.
x-collection: directus_activity
- name: Assets
description: Image typed files can be dynamically resized and transformed to fit any need.
- name: Authentication
description: All events that happen within Directus are tracked and stored in the activities collection. This gives you full accountability over everything that happens.
- name: Collection presets
description: Collection presets hold the preferences of individual users of the platform. This allows Directus to show and maintain custom item listings for users of the app.
- name: Presets
description: Presets hold the preferences of individual users of the platform. This allows Directus to show and maintain custom item listings for users of the app.
x-collection: directus_presets
- name: Collections
description: Collections are the individual collections of items, similar to tables in a database. Changes to collections will alter the schema of the database.
x-collection: directus_collections
- name: Extensions
description: Directus can easily be extended through the addition of several types of extensions, including layouts, interfaces, and modules.
- name: Fields
description: Fields are individual pieces of content within an item. They are mapped to columns in the database.
x-collection: directus_fields
- name: Files
description: Files can be saved in any given location. Directus has a powerful assets endpoint that can be used to generate thumbnails for images on the fly.
x-collection: directus_files
- name: Folders
description: Folders don't do anything yet, but will be used in the (near) future to be able to group files.
description: Group files by virtual folders.
x-collection: directus_folders
- name: Items
description: Items are individual pieces of data in your database. They can be anything, from articles, to IoT status checks.
- name: Mail
description: Send electronic mail through the electronic post.
- name: Permissions
description: Permissions control who has access to what and when.
- name: Projects
description: Projects are the individual tenants of the platform. Each project has its own database and data.
x-collection: directus_permissions
- name: Relations
description: What data is linked to what other data. Allows you to assign authors to articles, products to sales, and whatever other structures you can think of.
x-collection: directus_relations
- name: Revisions
description: Revisions are individual changes to items made. Directus keeps track of changes made, so you're able to revert to a previous state at will.
x-collection: directus_revisions
- name: Roles
description: Roles are groups of users that share permissions.
- name: SCIM
description: Directus partially supports Version 2 of System for Cross-domain Identity Management (SCIM). It is an open standard that allows for the exchange of user information between systems, therefore allowing users to be externally managed using the endpoints described below.
x-collection: directus_roles
- name: Server
description: Access to where Directus runs. Allows you to make sure your server has everything needed to run the platform, and check what kind of latency we're dealing with.
- name: Settings
description: Settings control the way the platform works and acts.
x-collection: directus_settings
- name: Users
description: Users are what gives you access to the data.
x-collection: directus_users
- name: Utilities
description: Directus comes with various utility endpoints you can use to simplify your development flow.
- name: Webhooks
description: Webhooks.
x-collection: directus_webhooks
paths:
# Activity
/activity:
$ref: './paths/activity/activitys.yaml'
$ref: "./paths/activity/activities.yaml"
/activity/comment:
$ref: './paths/activity/activity-comments.yaml'
$ref: "./paths/activity/activity-comments.yaml"
/activity/{id}:
$ref: './paths/activity/activity.yaml'
$ref: "./paths/activity/activity.yaml"
/activity/comment/{id}:
$ref: './paths/activity/activity-comment.yaml'
$ref: "./paths/activity/activity-comment.yaml"
# Assets
/assets/{key}:
$ref: './paths/assets/assets.yaml'
/assets/{id}:
$ref: "./paths/assets/assets.yaml"
# Authentication
/auth/login:
$ref: './paths/auth/login.yaml'
$ref: "./paths/auth/login.yaml"
/auth/refresh:
$ref: './paths/auth/refresh.yaml'
$ref: "./paths/auth/refresh.yaml"
/auth/logout:
$ref: './paths/auth/logout.yaml'
$ref: "./paths/auth/logout.yaml"
/auth/password/request:
$ref: './paths/auth/password-request.yaml'
$ref: "./paths/auth/password-request.yaml"
/auth/password/reset:
$ref: './paths/auth/password-reset.yaml'
$ref: "./paths/auth/password-reset.yaml"
/auth/sso:
$ref: './paths/auth/sso.yaml'
$ref: "./paths/auth/sso.yaml"
/auth/sso/{provider}:
$ref: './paths/auth/sso-provider.yaml'
$ref: "./paths/auth/sso-provider.yaml"
# Items
/items/{collection}:
$ref: './paths/items/items.yaml'
$ref: "./paths/items/items.yaml"
/items/{collection}/{id}:
$ref: './paths/items/item.yaml'
$ref: "./paths/items/item.yaml"
# Presets
/presets:
$ref: './paths/presets/presets.yaml'
$ref: "./paths/presets/presets.yaml"
/presets/{id}:
$ref: './paths/presets/preset.yaml'
$ref: "./paths/presets/preset.yaml"
# Collections
/collections:
$ref: './paths/collections/collections.yaml'
/collections/{collection}:
$ref: './paths/collections/collection.yaml'
$ref: "./paths/collections/collections.yaml"
/collections/{id}:
$ref: "./paths/collections/collection.yaml"
# Extensions
/interfaces:
$ref: './paths/extensions/interfaces.yaml'
/layouts:
$ref: './paths/extensions/layouts.yaml'
/modules:
$ref: './paths/extensions/modules.yaml'
/extensions/interfaces:
$ref: "./paths/extensions/interfaces.yaml"
/extensions/layouts:
$ref: "./paths/extensions/layouts.yaml"
/extensions/displays:
$ref: "./paths/extensions/displays.yaml"
/extensions/modules:
$ref: "./paths/extensions/modules.yaml"
# Fields
/fields:
$ref: './paths/fields/fields.yaml'
$ref: "./paths/fields/fields.yaml"
/fields/{collection}:
$ref: './paths/fields/collection-fields.yaml'
/fields/{collection}/{field}:
$ref: './paths/fields/collection-field.yaml'
$ref: "./paths/fields/collection-fields.yaml"
/fields/{collection}/{id}:
$ref: "./paths/fields/collection-field.yaml"
# Files
/files:
$ref: './paths/files/files.yaml'
$ref: "./paths/files/files.yaml"
/files/{id}:
$ref: './paths/files/file.yaml'
$ref: "./paths/files/file.yaml"
/files/{id}/revisions:
$ref: './paths/files/revisions.yaml'
$ref: "./paths/files/revisions.yaml"
/files/{id}/revisions/{offset}:
$ref: './paths/files/revision.yaml'
$ref: "./paths/files/revision.yaml"
# Folders
/folders:
$ref: './paths/folders/folders.yaml'
$ref: "./paths/folders/folders.yaml"
/folders/{id}:
$ref: './paths/folders/folder.yaml'
# Mail
/mail:
$ref: './paths/mail/mail.yaml'
$ref: "./paths/folders/folder.yaml"
# Permissions
/permissions:
$ref: './paths/permissions/permissions.yaml'
$ref: "./paths/permissions/permissions.yaml"
/permissions/me:
$ref: './paths/permissions/permissions-me.yaml'
$ref: "./paths/permissions/permissions-me.yaml"
/permissions/{id}:
$ref: './paths/permissions/permission.yaml'
/permissions/me/{collection}:
$ref: './paths/permissions/permissions-me-collection.yaml'
$ref: "./paths/permissions/permission.yaml"
# Relations
/relations:
$ref: './paths/relations/relations.yaml'
$ref: "./paths/relations/relations.yaml"
/relations/{id}:
$ref: './paths/relations/relation.yaml'
$ref: "./paths/relations/relation.yaml"
# Revisions
/revisions:
$ref: './paths/revisions/revisions.yaml'
$ref: "./paths/revisions/revisions.yaml"
/revisions/{id}:
$ref: './paths/revisions/revision.yaml'
$ref: "./paths/revisions/revision.yaml"
# Revisions
# Roles
/roles:
$ref: './paths/roles/roles.yaml'
$ref: "./paths/roles/roles.yaml"
/roles/{id}:
$ref: './paths/roles/role.yaml'
$ref: "./paths/roles/role.yaml"
# SCIM
/scim/v2/Users:
$ref: './paths/scim/users.yaml'
/scim/v2/Users/{id}:
$ref: './paths/scim/user.yaml'
/scim/v2/Groups:
$ref: './paths/scim/groups.yaml'
/scim/v2/Groups/{id}:
$ref: './paths/scim/group.yaml'
# Server
/server/info:
$ref: './paths/server/info.yaml'
servers:
- url: 'https://demo.directus.io/'
$ref: "./paths/server/info.yaml"
/server/ping:
$ref: './paths/server/ping.yaml'
servers:
- url: 'https://demo.directus.io/'
$ref: "./paths/server/ping.yaml"
# Settings
/settings:
$ref: './paths/settings/settings.yaml'
$ref: "./paths/settings/settings.yaml"
# Users
/users:
$ref: './paths/users/users.yaml'
/users/me:
$ref: './paths/users/me.yaml'
/users/invite:
$ref: './paths/users/user-invite.yaml'
$ref: "./paths/users/users.yaml"
/users/{id}:
$ref: './paths/users/user.yaml'
/users/invite/{token}:
$ref: './paths/users/user-invite-token.yaml'
$ref: "./paths/users/user.yaml"
/users/{id}/track/page:
$ref: './paths/users/user-tracking.yaml'
$ref: "./paths/users/user-tracking.yaml"
/users/invite:
$ref: "./paths/users/user-invite.yaml"
/users/invite/accept:
$ref: "./paths/users/user-invite-accept.yaml"
/users/me:
$ref: "./paths/users/me.yaml"
/users/me/track/page:
$ref: "./paths/users/me-tracking.yaml"
/users/me/tfa/enable:
$ref: "./paths/users/me-tfa-enable.yaml"
/users/me/tfa/disable:
$ref: "./paths/users/me-tfa-disable.yaml"
# Utilities
/utils/hash:
$ref: './paths/utils/hash.yaml'
$ref: "./paths/utils/hash.yaml"
/utils/hash/verify:
$ref: './paths/utils/hash-match.yaml'
$ref: "./paths/utils/hash-match.yaml"
/utils/random/string:
$ref: './paths/utils/random.yaml'
$ref: "./paths/utils/random.yaml"
/utils/sort/{collection}:
$ref: "./paths/utils/sort.yaml"
# Webhooks
/webhooks:
$ref: './paths/webhooks/webhooks.yaml'
$ref: "./paths/webhooks/webhooks.yaml"
/webhooks/{id}:
$ref: "./paths/webhooks/webhook.yaml"
components:
schemas:
Activity:
$ref: './components/activity.yaml'
Preset:
$ref: './components/preset.yaml'
Collection:
$ref: './components/collection.yaml'
Field:
$ref: './components/field.yaml'
File:
$ref: './components/file.yaml'
Folder:
$ref: './components/folder.yaml'
Item:
$ref: './components/item.yaml'
Permissions:
$ref: './components/permissions.yaml'
Relation:
$ref: './components/relation.yaml'
Revision:
$ref: './components/revision.yaml'
Role:
$ref: './components/role.yaml'
Setting:
$ref: './components/setting.yaml'
User:
$ref: './components/user.yaml'
Webhook:
$ref: './components/webhook.yaml'
parameters:
# All path parameters
Id:
$ref: './parameters/id.yaml'
UUId:
$ref: './parameters/uuid.yaml'
Collection:
$ref: './parameters/collection.yaml'
# All query parameters
q:
$ref: './parameters/q.yaml'
Page:
$ref: './parameters/page.yaml'
Offset:
$ref: './parameters/offset.yaml'
Single:
$ref: './parameters/single.yaml'
Sort:
$ref: './parameters/sort.yaml'
Meta:
$ref: './parameters/meta.yaml'
Limit:
$ref: './parameters/limit.yaml'
Filter:
$ref: './parameters/filter.yaml'
$ref: "./components/activity.yaml"
Presets:
$ref: "./components/preset.yaml"
Collections:
$ref: "./components/collection.yaml"
Fields:
$ref: './parameters/fields.yaml'
$ref: "./components/field.yaml"
Files:
$ref: "./components/file.yaml"
Folders:
$ref: "./components/folder.yaml"
Items:
$ref: "./components/item.yaml"
Permissions:
$ref: "./components/permissions.yaml"
Relations:
$ref: "./components/relation.yaml"
Revisions:
$ref: "./components/revision.yaml"
Roles:
$ref: "./components/role.yaml"
Settings:
$ref: "./components/setting.yaml"
Users:
$ref: "./components/user.yaml"
Webhooks:
$ref: "./components/webhook.yaml"
parameters:
# All path parameters
Id:
$ref: "./parameters/id.yaml"
UUId:
$ref: "./parameters/uuid.yaml"
Collection:
$ref: "./parameters/collection.yaml"
# All query parameters
Search:
$ref: "./parameters/search.yaml"
Page:
$ref: "./parameters/page.yaml"
Offset:
$ref: "./parameters/offset.yaml"
Single:
$ref: "./parameters/single.yaml"
Sort:
$ref: "./parameters/sort.yaml"
Meta:
$ref: "./parameters/meta.yaml"
Limit:
$ref: "./parameters/limit.yaml"
Filter:
$ref: "./parameters/filter.yaml"
Fields:
$ref: "./parameters/fields.yaml"
Mode:
$ref: './parameters/mode.yaml'
$ref: "./parameters/mode.yaml"
responses:
NotFoundError:
$ref: './responses/notFoundError.yaml'
$ref: "./responses/notFoundError.yaml"
UnauthorizedError:
$ref: './responses/unauthorizedError.yaml'
$ref: "./responses/unauthorizedError.yaml"
securitySchemes:
KeyAuth:
type: apiKey
in: query
name: access_token
description: Use the key 'admin' to authenticate to the public api.
Auth:
type: apiKey
in: header
name: 'Authorization'
description: To authenticate, use the "/auth/authenticate" endpoint with the credentials "admin@example.com" | "password". Use the api key here like so "Bearer \<key\>".
security:
name: "Authorization"
security:
- Auth: []
- KeyAuth: []

View File

@@ -1,6 +1,6 @@
description: Collection of which you want to retrieve the permissions.
description: Collection of which you want to retrieve the items from.
name: collection
in: path
required: true
schema:
type: string
type: string

View File

@@ -6,4 +6,4 @@ schema:
type: array
items:
type: string
pattern: '^(\[[^\[\]]*?\]){1}(\[(=|eq|<>|!=|neq|<|lt|<=|lte|>|gt|>=|gte|in|nin|null|nnull|contains|like|ncontains|nlike|rlike|nrlike|between|nbetween|empty|nempty|all|has)\])?=.*?$'
pattern: '^(\[[^\[\]]*?\]){1}(\[(_eq|_neq|_lt|_lte|_gt|_gte|_in|_nin|_null|_nnull|_contains|_ncontains|_between|_nbetween|_empty|_nempty)\])?=.*?$'

View File

@@ -1,6 +1,6 @@
description: Index of the file.
description: Index
name: id
in: path
required: true
schema:
type: integer
type: integer

View File

@@ -3,4 +3,4 @@ in: query
name: page
required: false
schema:
type: integer
type: integer

View File

@@ -1,6 +1,6 @@
description: Filter by items that contain the given search query in one of their fields.
in: query
name: q
name: search
required: false
schema:
type: string
type: string

View File

@@ -0,0 +1,30 @@
get:
operationId: getActivities
description: Returns a list of activity actions.
parameters:
- $ref: "../../openapi.yaml#/components/parameters/Fields"
- $ref: "../../openapi.yaml#/components/parameters/Limit"
- $ref: "../../openapi.yaml#/components/parameters/Meta"
- $ref: "../../openapi.yaml#/components/parameters/Offset"
- $ref: "../../openapi.yaml#/components/parameters/Single"
- $ref: "../../openapi.yaml#/components/parameters/Sort"
- $ref: "../../openapi.yaml#/components/parameters/Filter"
- $ref: "../../openapi.yaml#/components/parameters/Search"
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "../../openapi.yaml#/components/schemas/Activity"
description: Successful request
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Activity

View File

@@ -2,7 +2,8 @@ patch:
description: Update the content of an existing comment.
operationId: updateComment
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Id"
- $ref: "../../openapi.yaml#/components/parameters/Meta"
requestBody:
content:
application/json:
@@ -13,32 +14,32 @@ patch:
type: string
example: My updated comment
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Activity'
$ref: "../../openapi.yaml#/components/schemas/Activity"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Activity
- Activity
delete:
description: Delete an existing comment. Deleted comments can not be retrieved.
operationId: deleteComment
parameters:
- $ref: "../../openapi.yaml#/components/parameters/Id"
responses:
'203':
"203":
description: Deleted succsessfully
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Activity
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Id'
- Activity

View File

@@ -2,13 +2,13 @@ post:
description: Creates a new comment.
operationId: createComment
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Meta"
requestBody:
content:
application/json:
schema:
type: object
required: [ "collection", "item", "comment" ]
required: ["collection", "item", "comment"]
properties:
collection:
type: string
@@ -20,18 +20,18 @@ post:
type: string
example: A new comment
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Activity'
$ref: "../../openapi.yaml#/components/schemas/Activity"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Activity
- Activity

View File

@@ -1,13 +1,14 @@
get:
description: Retrieves the details of an existing activity action. Provide the primary
description:
Retrieves the details of an existing activity action. Provide the primary
key of the activity action and Directus will return the corresponding information.
operationId: getActivity
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Id'
- $ref: '../../openapi.yaml#/components/parameters/Fields'
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Id"
- $ref: "../../openapi.yaml#/components/parameters/Fields"
- $ref: "../../openapi.yaml#/components/parameters/Meta"
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -15,10 +16,10 @@ get:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Activity'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Activity"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Activity
- Activity

View File

@@ -1,30 +0,0 @@
get:
operationId: getActivitys
description: Returns a list of activity actions.
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Fields'
- $ref: '../../openapi.yaml#/components/parameters/Limit'
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: '../../openapi.yaml#/components/parameters/Offset'
- $ref: '../../openapi.yaml#/components/parameters/Single'
- $ref: '../../openapi.yaml#/components/parameters/Sort'
- $ref: '../../openapi.yaml#/components/parameters/Filter'
- $ref: '../../openapi.yaml#/components/parameters/q'
responses:
'200':
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '../../openapi.yaml#/components/schemas/Activity'
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
tags:
- Activity

View File

@@ -3,12 +3,12 @@ get:
- Assets
operationId: getAsset
description: Image typed files can be dynamically resized and transformed to fit any need.
security:
security:
- Auth: []
parameters:
- name: key
- name: id
in: path
description: private_hash of the file
description: The id of the file.
required: true
schema:
type: string
@@ -41,11 +41,11 @@ get:
minimum: 1
maximum: 100
responses:
'200':
"200":
description: Successful request
content:
text/plain:
schema:
type: string
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"

View File

@@ -8,7 +8,7 @@ post:
application/json:
schema:
type: object
required: [ email, password ]
required: [email, password]
properties:
email:
type: string
@@ -21,26 +21,29 @@ post:
example: password
mode:
type: string
enum: [jwt, cookie]
default: jwt
enum: ["json", cookie]
default: "json"
description: Choose between retrieving the token as a string, or setting it as a cookie.
otp:
type: string
description: If 2FA is enabled, you need to pass the one time password.
responses:
'200':
"200":
description: Successful authentification
content:
application/json:
schema:
type: object
properties:
public:
type: boolean
data:
type: object
properties:
token:
access_token:
type: string
user:
$ref: '../../openapi.yaml#/components/schemas/User'
example: eyJhbGciOiJI...
expires:
type: integer
example: 900
refresh_token:
type: string
example: yuOJkjdPXMd...

View File

@@ -3,6 +3,17 @@ post:
tags:
- Authentication
operationId: logout
requestBody:
content:
application/json:
schema:
type: object
required: [token]
properties:
refresh_token:
type: string
example: eyJ0eXAiOiJKV...
description: JWT access token you want to logout.
responses:
'200':
description: Request successful
"200":
description: Request successful

View File

@@ -8,16 +8,12 @@ post:
application/json:
schema:
type: object
required: [ email ]
required: [email]
properties:
email:
type: string
example: admin@example.com
description: Email address of the user you're requesting a reset for.
reset_url:
type: string
example: https://mydomain.com/passwordreset
description: Provide a custom reset url which the link in the Email will lead to. The reset token will be passed as a parameter.
responses:
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -8,7 +8,7 @@ post:
application/json:
schema:
type: object
required: [ token, password ]
required: [token, password]
properties:
token:
type: string
@@ -20,5 +20,5 @@ post:
format: password
description: New password for the user.
responses:
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -8,26 +8,31 @@ post:
application/json:
schema:
type: object
required: [ token ]
required: [token]
properties:
refresh_token:
type: string
example: eyJ0eXAiOiJKV...
description: JWT access token you want to refresh. This token can't be expired.
responses:
'200':
"200":
description: Successful request
content:
application/json:
schema:
type: object
properties:
public:
type: boolean
data:
type: object
properties:
token:
access_token:
type: string
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
example: eyJhbGciOiJI...
expires:
type: integer
example: 900
refresh_token:
type: string
example: Gy-caJMpmGTA...
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -10,7 +10,7 @@ get:
required: true
schema:
type: string
- $ref: '../../openapi.yaml#/components/parameters/Mode'
- $ref: "../../openapi.yaml#/components/parameters/Mode"
- name: redirect_url
in: query
required: true
@@ -18,7 +18,7 @@ get:
schema:
type: string
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -32,5 +32,5 @@ get:
properties:
token:
type: string
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -4,7 +4,7 @@ get:
operationId: sso
description: List the SSO providers.
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -18,5 +18,5 @@ get:
example: ["github", "facebook"]
items:
type: string
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -2,84 +2,123 @@ get:
description: Retrieves the details of a single collection.
operationId: getCollection
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Meta"
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Collection'
$ref: "../../openapi.yaml#/components/schemas/Collections"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Collections
- Collections
patch:
description: Update an existing collection.
operationId: updateCollection
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Meta"
requestBody:
content:
application/json:
schema:
type: object
properties:
note:
type: string
description: A note describing the collection.
hidden:
type: boolean
description: Whether or not the collection is hidden from the navigation in the admin app.
single:
type: string
description: Whether or not the collection is treated as a single record.
managed:
type: string
description: If Directus is tracking and managing this collection currently.
icon:
type: string
description: Name of a Google Material Design Icon that's assigned to this collection.
translation:
meta:
description: Metadata of the collection.
type: object
description: Key value pairs of how to show this collection's name in different languages in the admin app.
properties:
icon:
description: Name of a Google Material Design Icon that's assigned to this collection.
type: string
example: people
nullable: true
note:
description: A note describing the collection.
type: string
example: null
nullable: true
display_template:
description: Text representation of how items from this collection are shown across the system.
type: string
example: null
nullable: true
hidden:
description: Whether or not the collection is hidden from the navigation in the admin app.
type: boolean
example: false
singleton:
description: Whether or not the collection is treated as a single object.
type: boolean
example: false
translation:
description: Key value pairs of how to show this collection's name in different languages in the admin app.
type: string
example: null
nullable: true
archive_field:
description: What field holds the archive value.
type: string
example: null
nullable: true
archive_app_filter:
description: What value to use for "archived" items.
type: string
example: null
nullable: true
archive_value:
description: What value to use to "unarchive" items.
type: string
example: null
nullable: true
unarchive_value:
description: Whether or not to show the "archived" filter.
type: string
example: null
nullable: true
sort_field:
description: The sort field in the collection.
type: string
example: null
nullable: true
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Collection'
$ref: "../../openapi.yaml#/components/schemas/Collections"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Collections
- Collections
delete:
description: "Delete an existing collection. Warning: This will delete the whole collection, including the items within. Proceed with caution."
operationId: deleteCollection
responses:
'200':
"200":
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Collections
- Collections
parameters:
- name: collection
- name: id
in: path
required: true
description: The unique name of the collection.
description: Unique identifier of the collection.
schema:
type: string
type: string

View File

@@ -2,11 +2,11 @@ get:
description: Returns a list of the collections available in the project.
operationId: getCollections
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Offset'
- $ref: '../../openapi.yaml#/components/parameters/Single'
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Offset"
- $ref: "../../openapi.yaml#/components/parameters/Single"
- $ref: "../../openapi.yaml#/components/parameters/Meta"
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -16,63 +16,100 @@ get:
data:
type: array
items:
$ref: '../../openapi.yaml#/components/schemas/Collection'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Collections"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Collections
- Collections
post:
description: Create a new collection in Directus.
operationId: createCollection
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Meta'
- $ref: "../../openapi.yaml#/components/parameters/Meta"
requestBody:
content:
application/json:
schema:
type: object
required: [ collection, fields ]
required: [collection, fields]
properties:
collection:
type: string
description: Unique name of the collection.
example: my_collection
fields:
type: object
type: array
description: The fields contained in this collection. See the fields reference for more information. Each individual field requires field, type, and interface to be provided.
note:
type: string
description: A note describing the collection.
hidden:
type: string
description: Whether or not the collection is hidden from the navigation in the admin app.
single:
type: string
description: Whether or not the collection is treated as a single record.
managed:
type: string
description: If Directus is tracking and managing this collection currently.
items:
type: object
icon:
type: string
description: Name of a Google Material Design Icon that's assigned to this collection.
translation:
type: string
example: people
nullable: true
note:
description: A note describing the collection.
type: string
example: null
nullable: true
display_template:
description: Text representation of how items from this collection are shown across the system.
type: string
example: null
nullable: true
hidden:
description: Whether or not the collection is hidden from the navigation in the admin app.
type: boolean
example: false
singleton:
description: Whether or not the collection is treated as a single object.
type: boolean
example: false
translation:
description: Key value pairs of how to show this collection's name in different languages in the admin app.
type: string
example: null
nullable: true
archive_field:
description: What field holds the archive value.
type: string
example: null
nullable: true
archive_app_filter:
description: What value to use for "archived" items.
type: string
example: null
nullable: true
archive_value:
description: What value to use to "unarchive" items.
type: string
example: null
nullable: true
unarchive_value:
description: Whether or not to show the "archived" filter.
type: string
example: null
nullable: true
sort_field:
description: The sort field in the collection.
type: string
example: null
nullable: true
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Collection'
$ref: "../../openapi.yaml#/components/schemas/Collections"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Collections
- Collections

View File

@@ -0,0 +1,21 @@
get:
description: List all installed custom displays.
operationId: getDisplays
responses:
"200":
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
description: Successful request
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Extensions

View File

@@ -2,7 +2,7 @@ get:
description: List all installed custom interfaces.
operationId: getInterfaces
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -13,9 +13,9 @@ get:
type: array
items:
type: object
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Extensions
- Extensions

View File

@@ -2,7 +2,7 @@ get:
description: List all installed custom layouts.
operationId: getLayouts
responses:
'200':
"200":
content:
application/json:
schema:
@@ -13,9 +13,9 @@ get:
items:
type: object
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Extensions
- Extensions

View File

@@ -2,7 +2,7 @@ get:
description: List all installed custom modules.
operationId: getModules
responses:
'200':
"200":
content:
application/json:
schema:
@@ -13,9 +13,9 @@ get:
items:
type: object
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Extensions
- Extensions

View File

@@ -2,7 +2,7 @@ get:
description: Retrieves the details of a single field in a given collection.
operationId: getCollectionField
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -10,13 +10,13 @@ get:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Field'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Fields"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields
patch:
description: Update an existing field.
operationId: updateField
@@ -24,68 +24,172 @@ patch:
content:
application/json:
schema:
properties:
datatype:
description: SQL datatype of the column that corresponds to this field.
type: string
auto_increment:
description: If the value in this field is auto incremented. Only applies to integer type fields.
type: boolean
group:
description: What field group this field is part of.
type: string
hidden_browse:
description: If this field should be hidden from the item browse (listing) page.
type: boolean
hidden_detail:
description: If this field should be hidden from the item detail (edit) page.
type: boolean
interface:
description: What interface is used in the admin app to edit the value for this field.
type: string
locked:
description: If the field can be altered by the end user. Directus system fields have this value set to `true`.
type: boolean
note:
description: A user provided note for the field. Will be rendered alongside the interface on the edit page.
type: string
options:
description: Options for the interface that's used. This format is based on the individual interface.
type: object
primary_key:
description: If this field is the primary key of the collection.
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
type: boolean
required:
description: If this field requires a value.
type: boolean
signed:
description: If the value is signed or not. Only applies to integer type fields.
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
type: integer
translation:
description: 'Key value pair of `<language>: <translation>` that allows the user to change the displayed name of the field in the admin app.'
type: object
unique:
description: If the value of this field should be unique within the collection.
type: boolean
validation:
description: User provided regex that will be used in the API to validate incoming values.
type: string
width:
description: Width of the field on the edit form.
type: string
enum: [half, half-left, half-right, full, fill]
length:
description: Length of the field. Will be used in SQL to set the length property of the colummn. Requirement of this attribute depends on the provided datatype.
type: integer
type: object
properties:
field:
description: Unique name of the field. Field name is unique within the collection.
example: id
type: string
type:
description: Directus specific data type. Used to cast values in the API.
example: integer
type: string
schema:
description: The schema info.
type: object
properties:
name:
description: The name of the field.
example: title
type: string
table:
description: The collection of the field.
example: posts
type: string
type:
description: The type of the field.
example: string
type: string
default_value:
description: The default value of the field.
example: null
type: string
nullable: true
max_length:
description: The max length of the field.
example: null
type: integer
nullable: true
is_nullable:
description: If the field is nullable.
example: false
type: boolean
is_primary_key:
description: If the field is primary key.
example: false
type: boolean
has_auto_increment:
description: If the field has auto increment.
example: false
type: boolean
foreign_key_column:
description: Related column from the foreign key constraint.
example: null
type: string
nullable: true
foreign_key_table:
description: Related table from the foreign key constraint.
example: null
type: string
nullable: true
comment:
description: Comment as saved in the database.
example: null
type: string
nullable: true
schema:
description: Database schema (pg only).
example: public
type: string
foreign_key_schema:
description: Related schema from the foreign key constraint (pg only).
example: null
type: string
nullable: true
meta:
description: The meta info.
type: object
nullable: true
properties:
id:
description: Unique identifier for the field in the `directus_fields` collection.
example: 3
type: integer
collection:
description: Unique name of the collection this field is in.
example: posts
type: string
field:
description: Unique name of the field. Field name is unique within the collection.
example: title
type: string
special:
description: Transformation flag for field
example: null
type: array
items:
type: string
nullable: true
interface:
description:
What interface is used in the admin app to edit the value for this
field.
example: primary-key
type: string
nullable: true
options:
description:
Options for the interface that's used. This format is based on the
individual interface.
example: null
type: object
nullable: true
display:
description: What display is used in the admin app to display the value for this field.
example: null
type: string
nullable: true
display_options:
description: Options for the display that's used. This format is based on the individual display.
example: null
type: object
nullable: true
locked:
description:
If the field can be altered by the end user. Directus system fields
have this value set to `true`.
example: true
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
example: false
type: boolean
hidden:
description: If this field should be hidden.
example: true
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
example: 1
type: integer
nullable: true
width:
description: Width of the field on the edit form.
example: null
type: string
nullable: true
enum: [half, half-left, half-right, full, fill, null]
group:
description: What field group this field is part of.
example: null
type: integer
nullable: true
translation:
description:
"Key value pair of `<language>: <translation>` that allows the user
to change the displayed name of the field in the admin app."
example: null
type: object
nullable: true
note:
description:
A user provided note for the field. Will be rendered alongside the
interface on the edit page.
example: ""
type: string
nullable: true
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -93,26 +197,25 @@ patch:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Field'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Fields"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields
delete:
description: Delete an existing field.
operationId: deleteField
responses:
'200':
"200":
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields
parameters:
- name: collection
in: path
@@ -120,9 +223,9 @@ parameters:
schema:
type: string
required: true
- name: field
- name: id
in: path
description: The unique name of the field.
description: Unique identifier of the field.
schema:
type: string
required: true
required: true

View File

@@ -2,9 +2,9 @@ get:
description: Returns a list of the fields available in the given collection.
operationId: getCollectionFields
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Sort'
- $ref: "../../openapi.yaml#/components/parameters/Sort"
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -14,13 +14,13 @@ get:
data:
type: array
items:
$ref: '../../openapi.yaml#/components/schemas/Field'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Fields"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields
post:
description: Create a new field in a given collection.
operationId: createField
@@ -28,93 +28,191 @@ post:
content:
application/json:
schema:
required: [ field, datatype, type, length ]
required: [field, datatype, type, length]
type: object
properties:
field:
description: Unique name of the field. Field name is unique within the collection.
type: string
datatype:
description: SQL datatype of the column that corresponds to this field.
example: id
type: string
type:
description: Directus specific data type. Used to cast values in the API.
example: integer
type: string
auto_increment:
description: If the value in this field is auto incremented. Only applies to integer type fields.
type: boolean
group:
description: What field group this field is part of.
type: string
hidden_browse:
description: If this field should be hidden from the item browse (listing) page.
type: boolean
hidden_detail:
description: If this field should be hidden from the item detail (edit) page.
type: boolean
interface:
description: What interface is used in the admin app to edit the value for this field.
type: string
locked:
description: If the field can be altered by the end user. Directus system fields have this value set to `true`.
type: boolean
note:
description: A user provided note for the field. Will be rendered alongside the interface on the edit page.
type: string
options:
description: Options for the interface that's used. This format is based on the individual interface.
schema:
description: The schema info.
type: object
primary_key:
description: If this field is the primary key of the collection.
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
type: boolean
required:
description: If this field requires a value.
type: boolean
signed:
description: If the value is signed or not. Only applies to integer type fields.
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
type: integer
translation:
description: 'Key value pair of `<language>: <translation>` that allows the user to change the displayed name of the field in the admin app.'
properties:
name:
description: The name of the field.
example: title
type: string
table:
description: The collection of the field.
example: posts
type: string
type:
description: The type of the field.
example: string
type: string
default_value:
description: The default value of the field.
example: null
type: string
nullable: true
max_length:
description: The max length of the field.
example: null
type: integer
nullable: true
is_nullable:
description: If the field is nullable.
example: false
type: boolean
is_primary_key:
description: If the field is primary key.
example: false
type: boolean
has_auto_increment:
description: If the field has auto increment.
example: false
type: boolean
foreign_key_column:
description: Related column from the foreign key constraint.
example: null
type: string
nullable: true
foreign_key_table:
description: Related table from the foreign key constraint.
example: null
type: string
nullable: true
comment:
description: Comment as saved in the database.
example: null
type: string
nullable: true
schema:
description: Database schema (pg only).
example: public
type: string
foreign_key_schema:
description: Related schema from the foreign key constraint (pg only).
example: null
type: string
nullable: true
meta:
description: The meta info.
type: object
unique:
description: If the value of this field should be unique within the collection.
type: boolean
validation:
description: User provided regex that will be used in the API to validate incoming values.
type: string
width:
description: Width of the field on the edit form.
type: string
enum: [half, half-left, half-right, full, fill]
length:
description: Length of the field. Will be used in SQL to set the length property of the colummn. Requirement of this attribute depends on the provided datatype.
type: integer
type: object
nullable: true
properties:
id:
description: Unique identifier for the field in the `directus_fields` collection.
example: 3
type: integer
collection:
description: Unique name of the collection this field is in.
example: posts
type: string
field:
description: Unique name of the field. Field name is unique within the collection.
example: title
type: string
special:
description: Transformation flag for field
example: null
type: array
items:
type: string
nullable: true
interface:
description:
What interface is used in the admin app to edit the value for this
field.
example: primary-key
type: string
nullable: true
options:
description:
Options for the interface that's used. This format is based on the
individual interface.
example: null
type: object
nullable: true
display:
description: What display is used in the admin app to display the value for this field.
example: null
type: string
nullable: true
display_options:
description: Options for the display that's used. This format is based on the individual display.
example: null
type: object
nullable: true
locked:
description:
If the field can be altered by the end user. Directus system fields
have this value set to `true`.
example: true
type: boolean
readonly:
description: Prevents the user from editing the value in the field.
example: false
type: boolean
hidden:
description: If this field should be hidden.
example: true
type: boolean
sort:
description: Sort order of this field on the edit page of the admin app.
example: 1
type: integer
nullable: true
width:
description: Width of the field on the edit form.
example: null
type: string
nullable: true
enum: [half, half-left, half-right, full, fill, null]
group:
description: What field group this field is part of.
example: null
type: integer
nullable: true
translation:
description:
"Key value pair of `<language>: <translation>` that allows the user
to change the displayed name of the field in the admin app."
example: null
type: object
nullable: true
note:
description:
A user provided note for the field. Will be rendered alongside the
interface on the edit page.
example: ""
type: string
nullable: true
responses:
'200':
"200":
content:
application/json:
schema:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Field'
$ref: "../../openapi.yaml#/components/schemas/Fields"
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields
parameters:
- description: Unique identifier of the collection the item resides in.
in: path
name: collection
required: true
schema:
type: string
type: string

View File

@@ -2,10 +2,10 @@ get:
description: Returns a list of the fields available in the project.
operationId: getFields
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Limit'
- $ref: '../../openapi.yaml#/components/parameters/Sort'
- $ref: "../../openapi.yaml#/components/parameters/Limit"
- $ref: "../../openapi.yaml#/components/parameters/Sort"
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -15,10 +15,10 @@ get:
data:
type: array
items:
$ref: '../../openapi.yaml#/components/schemas/Field'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
'404':
$ref: '../../openapi.yaml#/components/responses/NotFoundError'
$ref: "../../openapi.yaml#/components/schemas/Fields"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
"404":
$ref: "../../openapi.yaml#/components/responses/NotFoundError"
tags:
- Fields
- Fields

View File

@@ -4,7 +4,7 @@ get:
- Files
operationId: getFile
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -12,9 +12,9 @@ get:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/File'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
$ref: "../../openapi.yaml#/components/schemas/Files"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
patch:
description: Update an existing file.
tags:
@@ -26,7 +26,7 @@ patch:
schema:
type: object
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -34,18 +34,18 @@ patch:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/File'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
$ref: "../../openapi.yaml#/components/schemas/Files"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
delete:
description: Delete an existing file.
tags:
- Files
operationId: deleteFile
responses:
'200':
"200":
description: Successful request
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
parameters:
- $ref: '../../openapi.yaml#/components/parameters/Id'
- $ref: "../../openapi.yaml#/components/parameters/Id"

View File

@@ -4,7 +4,7 @@ get:
- Files
operationId: getFiles
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -14,9 +14,9 @@ get:
data:
type: array
items:
$ref: '../../openapi.yaml#/components/schemas/File'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
$ref: "../../openapi.yaml#/components/schemas/Files"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"
post:
description: Create a new file.
tags:
@@ -31,7 +31,7 @@ post:
data:
type: string
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -39,6 +39,6 @@ post:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/File'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
$ref: "../../openapi.yaml#/components/schemas/Files"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

View File

@@ -4,7 +4,7 @@ get:
- Files
operationId: getFileRevision
parameters:
- $ref: '../../openapi.yaml#/components/parameters/UUId'
- $ref: "../../openapi.yaml#/components/parameters/UUId"
- name: offset
in: path
description: offset or revision
@@ -12,7 +12,7 @@ get:
schema:
type: integer
responses:
'200':
"200":
description: Successful request
content:
application/json:
@@ -20,6 +20,6 @@ get:
type: object
properties:
data:
$ref: '../../openapi.yaml#/components/schemas/Revision'
'401':
$ref: '../../openapi.yaml#/components/responses/UnauthorizedError'
$ref: "../../openapi.yaml#/components/schemas/Revisions"
"401":
$ref: "../../openapi.yaml#/components/responses/UnauthorizedError"

Some files were not shown because too many files have changed in this diff Show More