. * * The interactive user interfaces in modified source and object code versions * of this program must display Appropriate Legal Notices, as required under * Section 5 of the GNU Affero General Public License version 3. * * In accordance with Section 7(b) of the GNU Affero General Public License version 3, * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ namespace Espo\Tools\OpenApi; use Espo\Core\Acl\GlobalRestriction; use Espo\Core\Acl\Table; use Espo\Core\AclManager; use Espo\Core\Exceptions\BadRequest; use Espo\Core\Select\Where\Item\Type as WhereItemType; use Espo\Core\Utils\DataCache; use Espo\Core\Utils\FieldUtil; use Espo\Core\Utils\Json; use Espo\Core\Utils\Metadata; use Espo\Core\Utils\Module; use Espo\Entities\Team; use Espo\ORM\Defs; use Espo\ORM\Defs\Params\FieldParam; use Espo\ORM\Name\Attribute; use Espo\ORM\Type\RelationType; use Espo\Tools\OpenApi\Provider\Params; use ReflectionClass; use RuntimeException; use stdClass; class Provider { private const string CACHE_KEY = 'openApiSpec'; private const string PARAM_SPEC_DISABLED = 'apiSpecDisabled'; /** @var string[] */ private array $mandatoryEntityTypeList = [ Team::ENTITY_TYPE, ]; public function __construct( private Metadata $metadata, private Defs $defs, private FieldSchemaBuilderFactory $fieldSchemaBuilderFactory, private AclManager $aclManager, private FieldUtil $fieldUtil, private DataCache $dataCache, private Module $module, ) {} /** * @throws BadRequest */ public function get(Params $params): string { $this->validateParams($params); $params = $this->sanitizeParams($params); $data = $this->getData($params); return Json::encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); } private function getData(Params $params): stdClass { $cacheKey = $this->getCacheKey($params); if ($this->dataCache->has($cacheKey)) { $data = $this->dataCache->get($cacheKey); if (!$data instanceof stdClass) { throw new RuntimeException("Corrupted OpenAPI spec cache file."); } return $data; } $data = $this->buildData($params); $this->dataCache->store($cacheKey, $data); return $data; } private function buildData(Params $params): stdClass { $title = 'EspoCRM API'; if ($params->module) { $title = "EspoCRM ยท $params->module API"; } $spec = (object) [ 'openapi' => '3.1.1', 'info' => [ 'title' => $title, 'version' => '1.0.0', ], 'paths' => [], 'components' => [ 'schemas' => $this->buildSchemas($params), 'securitySchemes' => [ 'ApiKeyAuth' => [ 'type' => 'apiKey', 'in' => 'header', 'name' => 'X-Api-Key', ], ] ], 'security' => [ ['ApiKeyAuth' => []], ], 'servers' => [ [ 'url' => '{siteUrl}/api/v1', 'variables' => [ 'siteUrl' => [ 'default' => 'http://localhost', 'description' => 'An URL of you Espo instance.' ] ] ] ] ]; $this->buildCrud($params, $spec); return $spec; } private function getCacheKey(Params $params): string { $key = self::CACHE_KEY; if ($params->module !== null) { $key .= '_' . $params->module; return $key; } if ($params->skipCustom) { $key .= '_skipCustom'; } return $key; } /** * @return array> */ private function buildSchemas(Params $params): array { $output = []; foreach ($this->getEntityTypeList($params) as $entityType) { $entitySchemaName = $this->composeEntityTypeObjectSchemaName($entityType); $schema = $this->buildEntityTypeObjectSchema($params, $entityType); $required = $schema['required']; unset($schema['required']); $output[$entitySchemaName] = $schema; $createSchema = [ '$ref' => "#/components/schemas/$entitySchemaName", 'type' => Type::OBJECT, ]; $createSchema['required'] = $required; if (!$createSchema['required']) { unset($createSchema['required']); } $output[$entitySchemaName . '_create'] = $createSchema; } $output['whereItem'] = $this->buildWhereItemSchema(); return $output; } /** * @return string[] */ private function getEntityTypeList(Params $params): array { /** @var array> $defs */ $defs = $this->metadata->get('scopes') ?? []; $output = []; foreach ($defs as $scope => $it) { $object = $it['object'] ?? false; $entity = $it['entity'] ?? false; $disabled = $it['disabled'] ?? false; $module = $it['module'] ?? false; if ($params->module !== null && $params->module !== $module) { continue; } if ($params->skipCustom && $module === 'Custom') { continue; } if (!$entity) { continue; } if (!$object && !in_array($scope, $this->mandatoryEntityTypeList)) { continue; } if ($disabled) { continue; } $output[] = $scope; } usort($output, function ($a, $b) { return strcmp($a, $b); }); return $output; } private function composeEntityTypeObjectSchemaName(string $entityType): string { return $entityType; } /** * @return array{'type': string, 'required': string[], 'properties': array} */ private function buildEntityTypeObjectSchema(Params $params, string $entityType): array { $fieldDefsList = $this->defs->getEntity($entityType)->getFieldList(); $forbiddenList = $this->aclManager->getScopeRestrictedFieldList($entityType, GlobalRestriction::TYPE_FORBIDDEN); $internalList = $this->aclManager->getScopeRestrictedFieldList($entityType, GlobalRestriction::TYPE_INTERNAL); $readOnlyList = $this->aclManager->getScopeRestrictedFieldList($entityType, [ GlobalRestriction::TYPE_READ_ONLY, GlobalRestriction::TYPE_NON_ADMIN_READ_ONLY, GlobalRestriction::TYPE_ONLY_ADMIN, ]); $properties = []; $id = $this->buildEntityTypeObjectFieldSchema($entityType, Attribute::ID); $properties = array_merge($properties, $id->properties); $required = []; foreach ($fieldDefsList as $fieldDefs) { $field = $fieldDefs->getName(); if (in_array($field, $forbiddenList)) { continue; } if ($params->skipCustom && $fieldDefs->getParam('isCustom')) { continue; } if ( $fieldDefs->getParam(FieldParam::DISABLED) || $fieldDefs->getParam(FieldParam::UTILITY) || $fieldDefs->getParam(self::PARAM_SPEC_DISABLED) ) { continue; } $isReadOnly = in_array($field, $readOnlyList); $isInternal = in_array($field, $internalList); $itResult = $this->buildEntityTypeObjectFieldSchema($entityType, $field); foreach ($itResult->properties as $attribute => $attributeSchema) { if ($isReadOnly) { $attributeSchema['readOnly'] = true; } if ($isInternal) { $attributeSchema['writeOnly'] = true; } $properties[$attribute] = $attributeSchema; } foreach ($itResult->required as $attribute) { $required[] = $attribute; } $required = array_unique($required); $required = array_values($required); } return [ 'type' => Type::OBJECT, 'properties' => $properties, 'required' => $required, ]; } private function buildEntityTypeObjectFieldSchema(string $entityType, string $field): FieldSchemaResult { $builder = $this->fieldSchemaBuilderFactory->create($entityType, $field); return $builder->build($entityType, $field); } private function buildCrud(Params $params, stdClass $spec): void { foreach ($this->getEntityTypeList($params) as $entityType) { $this->buildCrudForEntityType($params, $spec, $entityType); } } private function buildCrudForEntityType(Params $params, stdClass $spec, string $entityType): void { $pathItemRoot = (object) []; $pathItemRoot->post = $this->prepareOperationCreate($entityType); $pathItemRoot->get = $this->prepareOperationList($params, $entityType); $pathItemRecord = (object) [ 'parameters' => [ [ 'name' => 'id', 'in' => 'path', 'required' => true, 'schema' => [ 'type' => Type::STRING, ], 'description' => 'A record ID.', ] ] ]; $pathItemRecord->get = $this->prepareOperationGet($entityType); $pathItemRecord->patch = $this->prepareOperationPatch($entityType); $pathItemRecord->delete = $this->prepareOperationDelete($entityType); $aclActionList = $this->metadata->get("scopes.$entityType.aclActionList"); $noRead = false; if (is_array($aclActionList)) { if (!in_array(Table::ACTION_CREATE, $aclActionList)) { unset($pathItemRoot->post); } if (!in_array(Table::ACTION_READ, $aclActionList)) { unset($pathItemRoot->get); unset($pathItemRecord->get); $noRead = true; } if (!in_array(Table::ACTION_EDIT, $aclActionList)) { unset($pathItemRecord->patch); } if (!in_array(Table::ACTION_DELETE, $aclActionList)) { unset($pathItemRecord->delete); } } $spec->paths["/$entityType"] = get_object_vars($pathItemRoot); $spec->paths["/$entityType/{id}"] = get_object_vars($pathItemRecord); if (!$noRead) { $this->addLinks($params, $entityType, $spec); } } private function getEntityTypeRef(string $entityType): string { return "#/components/schemas/$entityType"; } /** * @return array */ private function prepareOperationCreate(string $entityType): array { $ref = $this->getEntityTypeRef($entityType); $createOperation = [ 'tags' => [$entityType], 'requestBody' => [ 'content' => [ 'application/json' => [ 'schema' => [ '$ref' => $ref . '_create', ], ] ] ], 'parameters' => [ [ 'in' => 'header', 'name' => 'X-Skip-Duplicate-Check', 'schema' => [ 'type' => Type::STRING, 'enum' => [ 'true', 'false', ], ], 'description' => 'Skip duplicate check.' ], [ 'in' => 'header', 'name' => 'X-Duplicate-Source-Id', 'schema' => [ 'type' => Type::STRING, ], 'description' => 'A record ID of the entity that is being duplicated.', ] ], 'summary' => "Create $entityType", 'description' => "Create a new '$entityType' record.", ]; $responses = []; $responses['200'] = [ 'content' => [ 'application/json' => [ 'schema' => [ '$ref' => $ref, ], ], ], 'description' => 'Success.', ]; $responses['400'] = [ 'description' => 'Bad request. Might be a validation error. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $responses['409'] = [ 'description' => 'Conflict. May be a possible duplicate. Use X-Skip-Duplicate-Check to skip check.', ]; $createOperation['responses'] = $responses; return $createOperation; } /** * @return array */ private function prepareOperationPatch(string $entityType): array { $ref = $this->getEntityTypeRef($entityType); $parameters = []; if ($this->metadata->get(['recordDefs', $entityType, 'updateDuplicateCheck']) ) { $parameters[] = [ 'in' => 'header', 'name' => 'X-Skip-Duplicate-Check', 'schema' => [ 'type' => Type::STRING, 'enum' => [ 'true', 'false', ], ], 'description' => 'Skip duplicate check.' ]; } if ($this->metadata->get(['entityDefs', $entityType, 'optimisticConcurrencyControl'])) { $parameters[] = [ 'in' => 'header', 'name' => 'X-Version-Number', 'schema' => [ 'type' => Type::STRING, ], 'description' => "A version number for optimistic concurrency control. " . "Obtained from the 'versionNumber' field." ]; } $operation = [ 'tags' => [$entityType], 'requestBody' => [ 'content' => [ 'application/json' => [ 'schema' => ['$ref' => $ref], ] ] ], 'parameters' => $parameters, 'summary' => "Update $entityType", 'description' => "Update an existing '$entityType' record.", ]; $responses = []; $responses['200'] = [ 'content' => [ 'application/json' => [ 'schema' => [ '$ref' => $ref, ], ], ], 'description' => 'Success.', ]; $responses['400'] = [ 'description' => 'Bad request. Might be a validation error. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $responses['409'] = [ 'description' => 'Conflict.', ]; $operation['responses'] = $responses; return $operation; } /** * @return array */ private function prepareOperationDelete(string $entityType): array { $operation = [ 'tags' => [$entityType], 'summary' => "Remove $entityType", 'description' => "Remove an existing '$entityType' record.", ]; $responses = []; $responses['200'] = [ 'content' => [ 'application/json' => [ 'schema' => [ 'type' => Type::BOOLEAN, 'description' => 'Always true. Do not check the value.', ], ], ], 'description' => 'Success.', ]; $responses['400'] = [ 'description' => 'Bad request. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $operation['responses'] = $responses; return $operation; } /** * @return array */ private function prepareOperationGet(string $entityType): array { $ref = $this->getEntityTypeRef($entityType); $operation = [ 'tags' => [$entityType], 'summary' => "Read $entityType", 'description' => "Read an existing '$entityType' record.", ]; $responses = []; $responses['200'] = [ 'content' => [ 'application/json' => [ 'schema' => [ '$ref' => $ref, ], ], ], 'description' => 'Success.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $operation['responses'] = $responses; return $operation; } /** * @return array */ private function prepareOperationList(Params $params, string $entityType): array { $parameters = []; $parameters[] = [ 'in' => 'header', 'name' => 'X-No-Total', 'schema' => [ 'type' => Type::STRING, 'enum' => [ 'true', 'false', ], ], 'description' => 'Disable calculation of the total number of records.', ]; $parameters = array_merge($parameters, $this->prepareSearchParameters($params, $entityType)); return [ 'tags' => [$entityType], 'parameters' => $parameters, 'summary' => "List $entityType", 'description' => "List $entityType records.", 'responses' => $this->prepareListResponses($entityType), ]; } /** * @return string[] */ private function getSelectAttributeList(Params $params, string $entityType): array { $fieldDefsList = $this->defs->getEntity($entityType)->getFieldList(); $ignoreList = $this->aclManager->getScopeRestrictedFieldList($entityType, [ GlobalRestriction::TYPE_FORBIDDEN, GlobalRestriction::TYPE_INTERNAL, ]); $output = []; foreach ($fieldDefsList as $fieldDefs) { if (in_array($fieldDefs->getName(), $ignoreList)) { continue; } if ($params->skipCustom && $fieldDefs->getParam('isCustom')) { continue; } $output = array_merge( $output, $this->fieldUtil->getAttributeList($entityType, $fieldDefs->getName()) ); } $output = array_unique($output); return array_values($output); } /** * @return array */ private function buildWhereItemSchema(): array { $whereItemTypes = array_values((new ReflectionClass(WhereItemType::class))->getConstants()); return [ 'type' => Type::OBJECT, 'properties' => [ 'type' => [ 'type' => Type::STRING, 'enum' => $whereItemTypes, 'description' => 'An operator.', ], 'attribute' => [ 'type' => Type::STRING, 'description' => 'An attribute or field.', ], 'value' => [ 'oneOf' => [ [ 'type' => [ Type::STRING, Type::INTEGER, Type::NUMBER, Type::BOOLEAN, Type::NULL, ] ], [ 'type' => Type::ARRAY, 'items' => [ '$ref' => '#/components/schemas/whereItem', ] ], [ 'type' => Type::ARRAY, 'items' => [ 'type' => Type::STRING, ], ], ], 'description' => 'A value. A scalar, or an array of strings.', ], 'dateTime' => [ 'type' => Type::BOOLEAN, 'description' => "Set true for date-time fields.", ], "timeZone" => [ 'type' => Type::STRING, 'description' => "A time zone. For date-time fields.", ], ], 'required' => ['type'], 'description' => 'A where item.', ]; } /** * @return array[] */ private function prepareSearchParameters(Params $params, string $entityType): array { $parameters = [ [ 'name' => 'offset', 'in' => 'query', 'schema' => [ 'type' => Type::INTEGER, 'minimum' => 0, ], 'description' => 'A pagination offset.', ], [ 'name' => 'maxSize', 'in' => 'query', 'schema' => [ 'type' => Type::INTEGER, 'minimum' => 0, 'maximum' => 200, ], 'description' => 'The maximum number of records to return.', ], [ 'name' => 'orderBy', 'in' => 'query', 'schema' => [ 'type' => Type::STRING, ], 'description' => 'An attribute (field) to order by.', ], [ 'name' => 'order', 'in' => 'query', 'schema' => [ 'type' => Type::STRING, 'enum' => [ 'asc', 'desc', ], ], 'description' => 'An order direction.', ], [ 'name' => 'textFilter', 'in' => 'query', 'schema' => [ 'type' => Type::STRING, ], 'description' => 'A text filter query. Wildcard (*) is supported.' ], ]; $primaryFilterList = array_keys($this->metadata->get("selectDefs.$entityType.primaryFilterClassNameMap") ?? []); $boolFilterList = array_map( function ($it) { if (is_array($it)) { return $it['name'] ?? null; } return $it; }, $this->metadata->get("clientDefs.$entityType.boolFilterList") ?? [] ); if ($primaryFilterList) { $parameters[] = [ 'name' => 'primaryFilter', 'in' => 'query', 'schema' => [ 'type' => Type::STRING, 'enum' => $primaryFilterList, ], 'description' => 'A primary filter.', ]; } if ($boolFilterList) { $parameters[] = [ 'name' => 'boolFilterList', 'in' => 'query', 'schema' => [ 'type' => Type::ARRAY, 'items' => [ 'type' => Type::STRING, 'enum' => $boolFilterList, ], ], 'style' => 'deepObject', 'explode' => true, 'description' => 'Bool filters.', ]; } $selectAttributeList = $this->getSelectAttributeList($params, $entityType); if ($selectAttributeList) { $parameters[] = [ 'name' => 'attributeSelect', 'in' => 'query', 'schema' => [ 'type' => Type::ARRAY, 'items' => [ 'type' => Type::STRING, 'enum' => $selectAttributeList, ], ], 'style' => 'form', 'explode' => false, 'description' => 'Attributes to return. Select only the necessary ones to improve performance.', ]; } $parameters[] = [ 'name' => 'whereGroup', 'in' => 'query', 'schema' => [ 'type' => Type::ARRAY, 'items' => [ '$ref' => '#/components/schemas/whereItem', ], ], 'style' => 'deepObject', 'explode' => true, 'description' => 'Where items.', ]; return $parameters; } /** * @return array */ private function prepareList200Response(string $entityType): array { $ref = $this->getEntityTypeRef($entityType); return [ 'content' => [ 'application/json' => [ 'schema' => [ 'type' => Type::OBJECT, 'properties' => [ 'list' => [ 'type' => Type::ARRAY, 'items' => [ '$ref' => $ref, ], 'description' => 'Records.' ], 'total' => [ 'type' => Type::NUMBER, 'description' => "The total number of records. " . "If the total number is disabled, special values are returned: " . "-1 โ€“ there are more records to paginate, -2 โ€“ there are no more records." ], ], ], ], ], 'description' => 'Success.', ]; } /** * @return array */ private function prepareListResponses(string $entityType): array { $responses = []; $responses['200'] = $this->prepareList200Response($entityType); $responses['400'] = [ 'description' => 'Bad request. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; return $responses; } private function addLinks(Params $params, string $entityType, stdClass $spec): void { $relationList = $this->defs->getEntity($entityType)->getRelationList(); usort($relationList, function (Defs\RelationDefs $a, Defs\RelationDefs $b) { return strcmp($a->getName(), $b->getName()); }); $restrictedList = $this->aclManager->getScopeRestrictedLinkList($entityType, [ GlobalRestriction::TYPE_FORBIDDEN, ]); foreach ($relationList as $defs) { $link = $defs->getName(); if ( in_array($link, $restrictedList) || !in_array($defs->getType(), [ RelationType::HAS_MANY, RelationType::MANY_MANY, RelationType::HAS_CHILDREN, ]) || ( $this->metadata->get("entityDefs.$entityType.links.$link.readOnly") || $this->metadata->get("entityDefs.$entityType.links.$link.disabled") ) || $params->skipCustom && $defs->getParam('isCustom') || $this->metadata->get("entityDefs.$entityType.links.$link." . self::PARAM_SPEC_DISABLED) ) { continue; } $foreignEntityType = $this->defs ->getEntity($entityType) ->tryGetRelation($link) ?->tryGetForeignEntityType(); if (!$foreignEntityType) { continue; } $this->addLink($params, $entityType, $link, $spec); } } private function addLink(Params $params, string $entityType, string $link, stdClass $spec): void { $foreignEntityType = $this->defs ->getEntity($entityType) ->getRelation($link) ->getForeignEntityType(); if (!in_array($foreignEntityType, $this->getEntityTypeList($params))) { return; } $pathItem = [ 'parameters' => [ [ 'name' => 'id', 'in' => 'path', 'required' => true, 'schema' => [ 'type' => Type::STRING, ], 'description' => 'A record ID.', ] ] ]; $pathItem['get'] = $this->prepareOperationListLink($params, $entityType, $link, $foreignEntityType); $pathItem['post'] = $this->prepareOperationPostLink($entityType, $link, $foreignEntityType); $pathItem['delete'] = $this->prepareOperationDeleteLink($entityType, $link, $foreignEntityType); $spec->paths["/$entityType/{id}/$link"] = $pathItem; } /** * @return array */ private function prepareOperationListLink( Params $params, string $entityType, string $link, string $foreignEntityType, ): array { $parameters = []; $parameters = array_merge($parameters, $this->prepareSearchParameters($params, $foreignEntityType)); $operation = [ 'tags' => [$entityType], 'parameters' => $parameters, 'summary' => "List $entityType . $link", 'description' => "List '$foreignEntityType' records related through the '$link' link.", ]; $responses = $this->prepareListResponses($foreignEntityType); $operation['responses'] = $responses; return $operation; } /** * @return array */ private function prepareOperationPostLink(string $entityType, string $link, string $foreignEntityType): array { $createOperation = [ 'tags' => [$entityType], 'requestBody' => [ 'content' => [ 'application/json' => [ 'schema' => [ 'type' => Type::OBJECT, 'properties' => [ 'id' => [ 'type' => Type::STRING, 'description' => "An ID of $foreignEntityType record.", ], ] ] ] ] ], 'summary' => "Relate $entityType . $link", 'description' => "Relate '$foreignEntityType' record though the '$link' link.", ]; $responses = []; $responses['200'] = [ 'description' => 'Success.', ]; $responses['400'] = [ 'description' => 'Bad request. Might be a validation error. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $createOperation['responses'] = $responses; return $createOperation; } /** * @return array */ private function prepareOperationDeleteLink(string $entityType, string $link, string $foreignEntityType): array { $createOperation = [ 'tags' => [$entityType], 'requestBody' => [ 'content' => [ 'application/json' => [ 'schema' => [ 'type' => Type::OBJECT, 'properties' => [ 'id' => [ 'type' => Type::STRING, 'description' => "An ID of $foreignEntityType record.", ], ] ] ] ] ], 'summary' => "Unrelate $entityType . $link", 'description' => "Unrelate '$foreignEntityType' record related though the '$link' link.", ]; $responses = []; $responses['200'] = [ 'description' => 'Success.', ]; $responses['400'] = [ 'description' => 'Bad request. Might be a validation error. Check logs for details.', ]; $responses['403'] = [ 'description' => 'Forbidden. Might be an access control error. Check logs for details.', ]; $createOperation['responses'] = $responses; return $createOperation; } /** * @throws BadRequest */ private function validateParams(Params $params): void { if ( $params->module !== null && !in_array($params->module, $this->module->getList()) ) { throw new BadRequest("Module does not exist."); } } private function sanitizeParams(Params $params): Params { if ($params->module !== null) { $params = $params->withSkipCustom(true); } return $params; } }