diff --git a/application/Espo/Core/ORM/Type/FieldType.php b/application/Espo/Core/ORM/Type/FieldType.php index 5b442f5d9f..6401797c22 100644 --- a/application/Espo/Core/ORM/Type/FieldType.php +++ b/application/Espo/Core/ORM/Type/FieldType.php @@ -69,4 +69,9 @@ class FieldType * @since 9.3.0 */ public const DECIMAL = 'decimal'; + + /** + * @since 9.3.0 + */ + public const URL_MULTIPLE = 'urlMultiple'; } diff --git a/application/Espo/ORM/Defs/Params/FieldParam.php b/application/Espo/ORM/Defs/Params/FieldParam.php index e35557fd46..9e1098fa99 100644 --- a/application/Espo/ORM/Defs/Params/FieldParam.php +++ b/application/Espo/ORM/Defs/Params/FieldParam.php @@ -103,4 +103,39 @@ class FieldParam * Foreign field. */ public const FIELD = 'field'; + + /** + * Required. + * + * @since 9.3.0 + */ + public const REQUIRED = 'required'; + + /** + * Disabled. + * + * @since 9.3.0 + */ + public const DISABLED = 'disabled'; + + /** + * Utility. For internal purposes. + * + * @since 9.3.0 + */ + public const UTILITY = 'utility'; + + /** + * Min value. + * + * @since 9.3.0 + */ + public const MIN = 'min'; + + /** + * Max value. + * + * @since 9.3.0 + */ + public const MAX = 'max'; } diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 88797041af..58915b6742 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -67,7 +67,8 @@ "AddressCountry": "Address Country", "AppSecret": "App Secret", "OAuthProvider": "OAuth Provider", - "OAuthAccount": "OAuth Account" + "OAuthAccount": "OAuth Account", + "OpenApi": "OpenAPI" }, "scopeNamesPlural": { "Note": "Notes", @@ -125,7 +126,8 @@ "AddressCountry": "Address Countries", "AppSecret": "App Secrets", "OAuthProvider": "OAuth Providers", - "OAuthAccount": "OAuth Accounts" + "OAuthAccount": "OAuth Accounts", + "OpenApi": "OpenAPI" }, "labels": { "Previous Page": "Previous Page", diff --git a/application/Espo/Resources/metadata/fields/currency.json b/application/Espo/Resources/metadata/fields/currency.json index b4083b5475..df3364e4ac 100644 --- a/application/Espo/Resources/metadata/fields/currency.json +++ b/application/Espo/Resources/metadata/fields/currency.json @@ -79,7 +79,8 @@ "customizationInlineEditDisabledDisabled": true, "customizationDefaultView": "views/admin/field-manager/fields/currency-default", "customizationTooltipTextDisabled": true, - "maxLength": 3 + "maxLength": 3, + "apiSpecDisabled": true }, "converted": { "type": "currencyConverted", diff --git a/application/Espo/Resources/metadata/scopes/OpenApi.json b/application/Espo/Resources/metadata/scopes/OpenApi.json new file mode 100644 index 0000000000..e0b7cbbd6e --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/OpenApi.json @@ -0,0 +1,3 @@ +{ + "acl": "boolean" +} diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 92535577b7..6abeb7eb98 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -500,6 +500,11 @@ "route": "/OAuth/:id/connection", "actionClassName": "Espo\\Tools\\OAuth\\Api\\DeleteConnection" }, + { + "method": "get", + "route": "/OpenApi", + "actionClassName": "Espo\\Tools\\OpenApi\\Api\\GetSpec" + }, { "route": "/:controller/:id", "method": "get", diff --git a/application/Espo/Tools/OpenApi/Api/GetSpec.php b/application/Espo/Tools/OpenApi/Api/GetSpec.php new file mode 100644 index 0000000000..4f9b9d0ba5 --- /dev/null +++ b/application/Espo/Tools/OpenApi/Api/GetSpec.php @@ -0,0 +1,74 @@ +. + * + * 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\Api; + +use Espo\Core\Acl; +use Espo\Core\Api\Action; +use Espo\Core\Api\Request; +use Espo\Core\Api\Response; +use Espo\Core\Api\ResponseComposer; +use Espo\Core\Exceptions\Forbidden; +use Espo\Tools\OpenApi\ProviderFactory; + +/** + * @noinspection PhpUnused + */ +class GetSpec implements Action +{ + private const string SCOPE = 'OpenApi'; + + public function __construct( + private Acl $acl, + private ProviderFactory $providerFactory, + ) {} + + public function process(Request $request): Response + { + $this->checkAccess(); + + $provider = $this->providerFactory->create(); + + $spec = $provider->get(); + + return ResponseComposer::empty() + ->writeBody($spec) + ->setHeader('Content-Type', 'application/json'); + } + + /** + * @throws Forbidden + */ + private function checkAccess(): void + { + if (!$this->acl->checkScope(self::SCOPE)) { + throw new Forbidden("No access to OpenApi scope."); + } + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilder.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilder.php new file mode 100644 index 0000000000..78be9eceb2 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilder.php @@ -0,0 +1,13 @@ +> */ + private array $map = [ + FieldType::VARCHAR => VarcharType::class, + FieldType::URL => VarcharType::class, + FieldType::ENUM => EnumType::class, + FieldType::TEXT => TextType::class, + FieldType::WYSIWYG => TextType::class, + FieldType::NUMBER => NumberType::class, + FieldType::BOOL => BoolType::class, + FieldType::INT => IntType::class, + FieldType::FLOAT => FloatType::class, + FieldType::DECIMAL => DecimalType::class, + FieldType::CURRENCY => CurrencyType::class, + FieldType::AUTOINCREMENT => AutoincrementType::class, + FieldType::CURRENCY_CONVERTED => CurrencyConvertedType::class, + FieldType::FOREIGN => ForeignType::class, + FieldType::EMAIL => EmailType::class, + FieldType::PHONE => PhoneType::class, + FieldType::DATE => DateType::class, + FieldType::DATETIME => DatetimeType::class, + FieldType::DATETIME_OPTIONAL => DatetimeOptionalType::class, + FieldType::MULTI_ENUM => MultiEnumType::class, + FieldType::ARRAY => MultiEnumType::class, + FieldType::CHECKLIST => MultiEnumType::class, + FieldType::URL_MULTIPLE => MultiEnumType::class, + FieldType::LINK => LinkType::class, + FieldType::LINK_ONE => LinkType::class, + FieldType::FILE => LinkType::class, + FieldType::IMAGE => LinkType::class, + FieldType::LINK_PARENT => LinkParentType::class, + FieldType::LINK_MULTIPLE => LinkMultipleType::class, + ]; + + public function __construct( + private InjectableFactory $injectableFactory, + private Defs $defs, + ) {} + + public function create(string $entityType, string $field): FieldSchemaBuilder + { + $className = $this->getClassName($field, $entityType); + + return $this->injectableFactory->create($className); + } + + /** + * @return class-string + */ + private function getClassName(string $field, string $entityType): string + { + if ($field === Attribute::ID) { + return IdType::class; + } + + $fieldDefs = $this->defs + ->getEntity($entityType) + ->getField($field); + + $type = $fieldDefs->getType(); + + return $this->map[$type] ?? NoSupport::class; + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/AutoincrementType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/AutoincrementType.php new file mode 100644 index 0000000000..8483642422 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/AutoincrementType.php @@ -0,0 +1,28 @@ + Type::INTEGER, + 'readOnly' => true, + 'description' => 'An auto-increment number.', + ]; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/BoolType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/BoolType.php new file mode 100644 index 0000000000..408bb21f80 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/BoolType.php @@ -0,0 +1,32 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) [ + 'type' => Type::BOOLEAN, + 'readOnly' => $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false, + ]; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyConvertedType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyConvertedType.php new file mode 100644 index 0000000000..73b42110d1 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyConvertedType.php @@ -0,0 +1,32 @@ +type = Type::NUMBER; + $schema->readOnly = true; + $schema->description = 'A currency amount converted to the default currency.'; + + $schema->type = [ + $schema->type, + Type::NULL, + ]; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyType.php new file mode 100644 index 0000000000..73d73003c6 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/CurrencyType.php @@ -0,0 +1,80 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if ($fieldDefs->getParam(FieldParam::DECIMAL)) { + $schema->type = Type::STRING; + $schema->pattern = '^-?\d+(\.\d+)?$'; + $schema->description = 'A numeric string'; + } else { + $schema->type = Type::NUMBER; + + if ($fieldDefs->getParam(FieldParam::MIN) !== null) { + $schema->minimum = $fieldDefs->getParam(FieldParam::MIN); + } + + if ($fieldDefs->getParam(FieldParam::MAX) !== null) { + $schema->maximum = $fieldDefs->getParam(FieldParam::MAX); + } + } + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A currency amount.'; + + $codeSchema = (object) []; + $codeSchema->type = Type::STRING; + $codeSchema->enum = $this->configDataProvider->getCurrencyList(); + $codeSchema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $codeSchema->type = [ + $codeSchema->type, + Type::NULL, + ]; + } + + $codeSchema->description = 'A code.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + $required[] = $field . 'Currency'; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + $field . 'Currency' => get_object_vars($codeSchema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DateType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DateType.php new file mode 100644 index 0000000000..0bcf1cbab4 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DateType.php @@ -0,0 +1,48 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + $schema->format = 'date'; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A date.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeOptionalType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeOptionalType.php new file mode 100644 index 0000000000..29a64cc0bb --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeOptionalType.php @@ -0,0 +1,62 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->pattern = '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'; + $schema->examples = ['2026-11-29 12:34:56']; + $schema->description = 'A timestamp in UTC.'; + + $schemaDate = (object) []; + $schemaDate->type = Type::STRING; + $schemaDate->format = 'date'; + $schemaDate->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + $schemaDate->type = [ + $schemaDate->type, + Type::NULL, + ]; + + $schema->description = "Specified if the '$field' field is all-day."; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + $field . 'Date' => get_object_vars($schemaDate), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeType.php new file mode 100644 index 0000000000..26dedb4dbf --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DatetimeType.php @@ -0,0 +1,49 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->pattern = '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'; + $schema->examples = ['2026-11-29 12:34:56']; + $schema->description = 'A timestamp in UTC.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DecimalType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DecimalType.php new file mode 100644 index 0000000000..83c27bac30 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/DecimalType.php @@ -0,0 +1,48 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + $schema->pattern = '^-?\d+(\.\d+)?$'; + $schema->description = 'A numeric string.'; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} + diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EmailType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EmailType.php new file mode 100644 index 0000000000..e98feae210 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EmailType.php @@ -0,0 +1,86 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->maxLength = self::DEFAULT_MAX_LENGTH; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A primary email address.'; + + $itemSchema = (object) []; + $itemSchema->type = Type::OBJECT; + $itemSchema->properties = [ + 'emailAddress' => [ + 'type' => Type::STRING, + ], + 'primary' => [ + 'type' => Type::BOOLEAN, + ], + 'optOut' => [ + 'type' => Type::BOOLEAN, + ], + 'invalid' => [ + 'type' => Type::BOOLEAN, + ], + 'lower' => [ + 'type' => Type::STRING, + 'readOnly' => true, + ], + ]; + $itemSchema->required = [ + 'emailAddress', + 'primary', + ]; + + $dataSchema = (object) []; + $dataSchema->type = Type::ARRAY; + $dataSchema->items = get_object_vars($itemSchema); + $dataSchema->description = 'Multiple email addresses'; + + $dataSchema->type = [ + $dataSchema->type, + Type::NULL, + ]; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + $field . 'Data' => get_object_vars($dataSchema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EnumType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EnumType.php new file mode 100644 index 0000000000..46cf6af9eb --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/EnumType.php @@ -0,0 +1,56 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + $optionList = $this->optionsProvider->get($fieldDefs); + + if ($optionList) { + if (in_array('', $optionList) && !$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + Type::STRING, + Type::NULL, + ]; + } + + $optionList = array_filter($optionList, fn ($it) => $it !== ''); + $optionList = array_values($optionList); + + $schema->enum = $optionList; + } + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/FloatType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/FloatType.php new file mode 100644 index 0000000000..65c826a29e --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/FloatType.php @@ -0,0 +1,53 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::NUMBER; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if ($fieldDefs->getParam(FieldParam::MIN) !== null) { + $schema->minimum = $fieldDefs->getParam(FieldParam::MIN); + } + + if ($fieldDefs->getParam(FieldParam::MAX) !== null) { + $schema->maximum = $fieldDefs->getParam(FieldParam::MAX); + } + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/ForeignType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/ForeignType.php new file mode 100644 index 0000000000..9604250ba2 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/ForeignType.php @@ -0,0 +1,83 @@ +readOnly = true; + + $fieldDefs = $this->defs->getEntity($entityType)->getField($field); + + $link = $fieldDefs->getParam(FieldParam::LINK); + $foreignField = $fieldDefs->getParam(FieldParam::FIELD); + + $foreignEntityType = $this->defs + ->getEntity($entityType) + ->tryGetRelation($link) + ?->tryGetForeignEntityType(); + + if (!$foreignEntityType) { + return new FieldSchemaResult([]); + } + + $foreignFieldDefs = $this->defs + ->getEntity($foreignEntityType) + ->tryGetField($foreignField); + + if (!$foreignFieldDefs) { + return new FieldSchemaResult([]); + } + + $fieldType = $foreignFieldDefs->getType(); + + if ( + $fieldType === FieldType::ENUM || + $fieldType === FieldType::VARCHAR || + $fieldType === FieldType::TEXT || + $fieldType === FieldType::DATE || + $fieldType === FieldType::DATETIME || + $fieldType === FieldType::EMAIL || + $fieldType === FieldType::PHONE || + $fieldType === FieldType::WYSIWYG || + $fieldType === FieldType::DECIMAL + ) { + $schema->type = Type::STRING; + } else if ( + $foreignEntityType === FieldType::INT + ) { + $schema->type = Type::INTEGER; + } else if ( + $foreignEntityType === FieldType::FLOAT + ) { + $schema->type = Type::NUMBER; + } else { + return new FieldSchemaResult([]); + } + + $schema->type = [ + $schema->type, + Type::NULL, + ]; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} + diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IdType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IdType.php new file mode 100644 index 0000000000..9500ea872a --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IdType.php @@ -0,0 +1,40 @@ +defs->getEntity($entityType)->tryGetField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = true; + + $dbType = $fieldDefs?->getParam(Defs\Params\FieldParam::DB_TYPE); + + if ($dbType === Types::BIGINT || $dbType === Types::INTEGER) { + $schema->type = Type::INTEGER; + } + + $schema->description = 'An ID.'; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} + diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IntType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IntType.php new file mode 100644 index 0000000000..93af8fa31f --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/IntType.php @@ -0,0 +1,53 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::INTEGER; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if ($fieldDefs->getParam(FieldParam::MIN) !== null) { + $schema->minimum = $fieldDefs->getParam(FieldParam::MIN); + } + + if ($fieldDefs->getParam(FieldParam::MAX) !== null) { + $schema->maximum = $fieldDefs->getParam(FieldParam::MAX); + } + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkMultipleType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkMultipleType.php new file mode 100644 index 0000000000..5a161a949e --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkMultipleType.php @@ -0,0 +1,158 @@ +defs->getEntity($entityType)->getField($field); + $linkDefs = $this->defs->getEntity($entityType)->tryGetRelation($field); + + $idsSchema = (object) [ + 'type' => Type::ARRAY, + 'items' => [ + 'type' => Type::STRING, + ], + ]; + + $idsSchema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + $foreignEntityType = null; + + if ($linkDefs && $linkDefs->tryGetForeignEntityType()) { + $foreignEntityType = $linkDefs->tryGetForeignEntityType(); + + $idsSchema->description = "IDs of records of the $foreignEntityType type."; + } + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $idsSchema->type = [ + $idsSchema->type, + Type::NULL, + ]; + } + + $namesSchema = (object) []; + $namesSchema->type = Type::OBJECT; + $namesSchema->additionalProperties = [ + 'type' => Type::STRING, + ]; + $namesSchema->readOnly = true; + $namesSchema->description = 'An {ID => record name} map.'; + + $namesSchema->type = [ + $namesSchema->type, + Type::NULL, + ]; + + $output = [ + $field . 'Ids' => get_object_vars($idsSchema), + $field . 'Names' => get_object_vars($namesSchema), + ]; + + /** @var ?array $columns */ + $columns = $fieldDefs->getParam('columns'); + + if (is_array($columns) && $foreignEntityType) { + $columnsSchema = (object) []; + $columnsSchema->type = Type::OBJECT; + $columnsSchema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + $columnsSchema->description = 'An {ID => object} map. Relationship column values.'; + + $columnsSchema->type = [ + $columnsSchema->type, + Type::NULL, + ]; + + $properties = array_map(function ($columnField) use ($entityType, $foreignEntityType) { + return $this->prepareColumnSchema($entityType, $foreignEntityType, $columnField); + }, $columns); + + $columnsSchema->additionalProperties = [ + 'type' => Type::OBJECT, + 'properties' => $properties, + ]; + + $output[$field . 'Columns'] = get_object_vars($columnsSchema); + } + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field . 'Ids'; + } + + return new FieldSchemaResult( + properties: $output, + required: $required, + ); + } + + /** + * @return array + */ + private function prepareColumnSchema(string $entityType, string $foreignEntityType, string $columnField): array + { + $colSchema = (object) []; + + $fieldDefs = $this->defs->getEntity($foreignEntityType)->tryGetField($columnField); + + if (!$fieldDefs) { + $fieldDefs = $this->defs->getEntity($entityType)->tryGetField($columnField); + } + + if (!$fieldDefs) { + return get_object_vars($colSchema); + } + + $optionList = $this->enumOptionsProvider->get($fieldDefs); + + if ($optionList) { + $colSchema->type = Type::STRING; + + if (in_array('', $optionList)) { + $colSchema->type = [ + $colSchema->type, + Type::NULL, + ]; + } + + $optionList = array_filter($optionList, fn($it) => $it !== ''); + $optionList = array_values($optionList); + + $colSchema->enum = $optionList; + + return get_object_vars($colSchema); + } + + if ($fieldDefs->getType() === FieldType::BOOL) { + $colSchema->type = Type::BOOLEAN; + } else if ($fieldDefs->getType() === FieldType::INT) { + $colSchema->type = Type::INTEGER; + } else if ($fieldDefs->getType() === FieldType::FLOAT) { + $colSchema->type = Type::NUMBER; + } else if ($fieldDefs->getType() === FieldType::VARCHAR) { + $colSchema->type = Type::STRING; + + if ($fieldDefs->getParam(FieldParam::MAX_LENGTH)) { + $colSchema->maxLength = $fieldDefs->getParam(FieldParam::MAX_LENGTH); + } + } + + return get_object_vars($colSchema); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkParentType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkParentType.php new file mode 100644 index 0000000000..2bd8a6806b --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkParentType.php @@ -0,0 +1,78 @@ +defs->getEntity($entityType)->getField($field); + + $idSchema = (object) []; + $idSchema->type = Type::STRING; + $idSchema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + $idSchema->description = "A foreign record ID."; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $idSchema->type = [ + $idSchema->type, + Type::NULL, + ]; + } + + $typeSchema = (object) []; + $typeSchema->type = Type::STRING; + $typeSchema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $typeSchema->type = [ + $typeSchema->type, + Type::NULL, + ]; + } + + if ($fieldDefs->getParam('entityList')) { + $typeSchema->enum = $fieldDefs->getParam('entityList'); + } + + $typeSchema->description = "An entity type."; + + $nameSchema = (object) []; + $nameSchema->type = Type::STRING; + $nameSchema->readOnly = true; + + $nameSchema->type = [ + $nameSchema->type, + Type::NULL, + ]; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field . 'Id'; + $required[] = $field . 'Type'; + } + + $nameSchema->description = 'A foreign record name.'; + + return new FieldSchemaResult( + properties: [ + $field . 'Id' => get_object_vars($idSchema), + $field . 'Type' => get_object_vars($typeSchema), + $field . 'Name' => get_object_vars($nameSchema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkType.php new file mode 100644 index 0000000000..d29d3ae981 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/LinkType.php @@ -0,0 +1,63 @@ +defs->getEntity($entityType)->getField($field); + $linkDefs = $this->defs->getEntity($entityType)->tryGetRelation($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if ($linkDefs && $linkDefs->tryGetForeignEntityType()) { + $foreignEntityType = $linkDefs->tryGetForeignEntityType(); + + $schema->description = "An ID of the record of the $foreignEntityType type."; + } + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $nameSchema = (object) []; + $nameSchema->type = Type::STRING; + $nameSchema->readOnly = true; + $nameSchema->type = [ + $nameSchema->type, + Type::NULL, + ]; + + $nameSchema->description = 'A foreign record name.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field . 'Id'; + } + + return new FieldSchemaResult( + properties: [ + $field . 'Id' => get_object_vars($schema), + $field . 'Name' => get_object_vars($nameSchema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/MultiEnumType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/MultiEnumType.php new file mode 100644 index 0000000000..ba646496dd --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/MultiEnumType.php @@ -0,0 +1,54 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::ARRAY; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + $optionList = $this->optionsProvider->get($fieldDefs); + + $itemSchema = (object) [ + 'type' => Type::STRING, + ]; + + if ($optionList) { + $itemSchema->enum = $optionList; + } + + $schema->items = get_object_vars($itemSchema); + + $schema->description = 'A multi-enum.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/NoSupport.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/NoSupport.php new file mode 100644 index 0000000000..21ed139d77 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/NoSupport.php @@ -0,0 +1,17 @@ +type = Type::STRING; + $schema->readOnly = true; + + $schema->description = 'A number. Auto-incrementing with a prefix.'; + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/PhoneType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/PhoneType.php new file mode 100644 index 0000000000..e787d92eab --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/PhoneType.php @@ -0,0 +1,85 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->maxLength = self::DEFAULT_MAX_LENGTH; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A primary phone number.'; + + $itemSchema = (object) []; + $itemSchema->type = Type::OBJECT; + $itemSchema->properties = [ + 'phoneNumber' => [ + 'type' => Type::STRING, + ], + 'primary' => [ + 'type' => Type::BOOLEAN, + ], + 'optOut' => [ + 'type' => Type::BOOLEAN, + ], + 'invalid' => [ + 'type' => Type::BOOLEAN, + ], + 'type' => [ + 'type' => Type::STRING, + 'enum' => $fieldDefs->getParam('typeList'), + ], + ]; + $itemSchema->required = [ + 'phoneNumber', + 'primary', + ]; + + $dataSchema = (object) []; + $dataSchema->type = Type::ARRAY; + $dataSchema->items = get_object_vars($itemSchema); + $dataSchema->type = [ + $dataSchema->type, + Type::NULL, + ]; + $dataSchema->description = 'Multiple phone numbers.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + $field . 'Data' => get_object_vars($dataSchema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/TextType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/TextType.php new file mode 100644 index 0000000000..a9791f7bd7 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/TextType.php @@ -0,0 +1,52 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + + if ($fieldDefs->getParam(FieldParam::MAX_LENGTH)) { + $schema->maxLength = $fieldDefs->getParam(FieldParam::MAX_LENGTH); + } + + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A multi-line text.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaBuilders/VarcharType.php b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/VarcharType.php new file mode 100644 index 0000000000..d7cdf5b9df --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaBuilders/VarcharType.php @@ -0,0 +1,50 @@ +defs->getEntity($entityType)->getField($field); + + $schema = (object) []; + $schema->type = Type::STRING; + $schema->maxLength = $fieldDefs->getParam(FieldParam::MAX_LENGTH) ?? self::DEFAULT_MAX_LENGTH; + $schema->readOnly = $fieldDefs->getParam(FieldParam::READ_ONLY) ?? false; + + if (!$fieldDefs->getParam(FieldParam::REQUIRED)) { + $schema->type = [ + $schema->type, + Type::NULL, + ]; + } + + $schema->description = 'A one-line string.'; + + $required = []; + + if ($fieldDefs->getParam(FieldParam::REQUIRED) && !$fieldDefs->getParam(FieldParam::DEFAULT)) { + $required[] = $field; + } + + return new FieldSchemaResult( + properties: [ + $field => get_object_vars($schema), + ], + required: $required, + ); + } +} diff --git a/application/Espo/Tools/OpenApi/FieldSchemaResult.php b/application/Espo/Tools/OpenApi/FieldSchemaResult.php new file mode 100644 index 0000000000..ea4fc0a5f2 --- /dev/null +++ b/application/Espo/Tools/OpenApi/FieldSchemaResult.php @@ -0,0 +1,15 @@ +> $properties + * @param string[] $required + */ + public function __construct( + public array $properties, + public array $required = [], + ) {} +} diff --git a/application/Espo/Tools/OpenApi/Provider.php b/application/Espo/Tools/OpenApi/Provider.php new file mode 100644 index 0000000000..e613c92807 --- /dev/null +++ b/application/Espo/Tools/OpenApi/Provider.php @@ -0,0 +1,1016 @@ +. + * + * 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\Select\Where\Item\Type as WhereItemType; +use Espo\Core\Utils\FieldUtil; +use Espo\Core\Utils\Json; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Defs; +use Espo\ORM\Defs\Params\FieldParam; +use Espo\ORM\Name\Attribute; +use Espo\ORM\Type\RelationType; +use ReflectionClass; +use stdClass; + +/** + * @todo Cache. + */ +class Provider +{ + public function __construct( + private Metadata $metadata, + private Defs $defs, + private FieldSchemaBuilderFactory $fieldSchemaBuilderFactory, + private AclManager $aclManager, + private FieldUtil $fieldUtil, + ) {} + + public function get(): string + { + $spec = (object) [ + 'openapi' => '3.1.1', + 'info' => [ + 'title' => 'EspoCRM API', + 'version' => '1.0.0', + ], + 'paths' => [], + 'components' => [ + 'schemas' => $this->buildSchemas(), + '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($spec); + + return Json::encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + /** + * @return array> + */ + private function buildSchemas(): array + { + $output = []; + + foreach ($this->getEntityTypeList() as $entityType) { + $entitySchemaName = $this->composeEntityTypeObjectSchemaName($entityType); + + $schema = $this->buildEntityTypeObjectSchema($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(): 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 ($module === 'Custom') { + continue; + } + + if (!$object || !$entity) { + 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(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 ($fieldDefs->getParam('isCustom')) { + continue; + } + + if ( + $fieldDefs->getParam(FieldParam::DISABLED) || + $fieldDefs->getParam(FieldParam::UTILITY) || + $fieldDefs->getParam('apiSpecDisabled') + ) { + 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(stdClass $spec): void + { + foreach ($this->getEntityTypeList() as $entityType) { + $this->buildCrudForEntityType($spec, $entityType); + } + } + + private function buildCrudForEntityType(stdClass $spec, string $entityType): void + { + $pathItemRoot = (object) []; + $pathItemRoot->post = $this->prepareOperationCreate($entityType); + $pathItemRoot->get = $this->prepareOperationList($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($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.', + ] + ], + '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, + '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], + '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], + '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(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[] = $this->prepareSearchParam($entityType); + + return [ + 'tags' => [$entityType], + 'parameters' => $parameters, + 'description' => "List $entityType records.", + 'responses' => $this->prepareListResponses($entityType), + ]; + } + + /** + * @return string[] + */ + private function getSelectAttributeList(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; + } + + $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 prepareSearchParam(string $entityType): array + { + $searchParamsProperties = [ + 'offset' => [ + 'type' => Type::INTEGER, + 'minimum' => 0, + 'description' => 'A pagination offset.', + ], + 'maxSize' => [ + 'type' => Type::INTEGER, + 'minimum' => 0, + 'maximum' => 200, + 'description' => 'The maximum number of records to return.', + ], + 'orderBy' => [ + 'type' => Type::STRING, + 'description' => 'An attribute (field) to order by.', + ], + 'order' => [ + 'type' => Type::STRING, + 'enum' => [ + 'asc', + 'desc', + ], + 'description' => 'An order direction.', + ], + 'textFilter' => [ + '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) { + $searchParamsProperties['primaryFilter'] = [ + 'type' => Type::STRING, + 'enum' => $primaryFilterList, + 'description' => 'A primary filter.', + ]; + } + + if ($boolFilterList) { + $searchParamsProperties['boolFilterList'] = [ + 'type' => Type::ARRAY, + 'items' => [ + 'type' => Type::STRING, + 'enum' => $boolFilterList, + ], + 'description' => 'Bool filters.', + ]; + } + + $selectAttributeList = $this->getSelectAttributeList($entityType); + + if ($selectAttributeList) { + $searchParamsProperties['select'] = [ + 'type' => Type::ARRAY, + 'items' => [ + 'type' => Type::STRING, + 'enum' => $selectAttributeList, + ], + 'description' => 'Attributes to return. Select only the necessary ones to improve performance.', + ]; + } + + $searchParamsProperties['where'] = [ + 'type' => Type::ARRAY, + 'items' => [ + '$ref' => '#/components/schemas/whereItem', + ], + 'description' => 'Where items.', + ]; + + return [ + 'in' => 'query', + 'name' => 'searchParams', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => Type::OBJECT, + 'properties' => $searchParamsProperties, + ], + ], + ], + 'description' => 'Disable calculation of the total number of records.', + ]; + } + + /** + * @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(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") + ) || + $defs->getParam('isCustom') + ) { + continue; + } + + $foreignEntityType = $this->defs + ->getEntity($entityType) + ->tryGetRelation($link) + ?->tryGetForeignEntityType(); + + if (!$foreignEntityType) { + continue; + } + + $this->addLink($entityType, $link, $spec); + } + } + + private function addLink(string $entityType, string $link, stdClass $spec): void + { + $foreignEntityType = $this->defs + ->getEntity($entityType) + ->getRelation($link) + ->getForeignEntityType(); + + if (!in_array($foreignEntityType, $this->getEntityTypeList())) { + return; + } + + $pathItem = [ + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => Type::STRING, + ], + 'description' => 'A record ID.', + ] + ] + ]; + + $pathItem['get'] = $this->prepareOperationListLink($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(string $entityType, string $link, string $foreignEntityType): array + { + $parameters = []; + + $searchParam = $this->prepareSearchParam($foreignEntityType); + + $parameters[] = $searchParam; + + $operation = [ + 'tags' => [$entityType], + 'parameters' => $parameters, + '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.", + ], + ] + ] + ] + ] + ], + '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.", + ], + ] + ] + ] + ] + ], + '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; + } +} diff --git a/application/Espo/Tools/OpenApi/ProviderFactory.php b/application/Espo/Tools/OpenApi/ProviderFactory.php new file mode 100644 index 0000000000..1de53b39a7 --- /dev/null +++ b/application/Espo/Tools/OpenApi/ProviderFactory.php @@ -0,0 +1,22 @@ +injectableFactory->createWithBinding( + Provider::class, + BindingContainerBuilder::create() + ->build() + ); + } +} diff --git a/application/Espo/Tools/OpenApi/Type.php b/application/Espo/Tools/OpenApi/Type.php new file mode 100644 index 0000000000..3d4b0c9584 --- /dev/null +++ b/application/Espo/Tools/OpenApi/Type.php @@ -0,0 +1,14 @@ +getParam('optionsPath'); + /** @var ?string $path */ + $ref = $fieldDefs->getParam('optionsReference'); + + if (!$path && $ref && str_contains($ref, '.')) { + [$refEntityType, $refField] = explode('.', $ref); + + $path = "entityDefs.$refEntityType.fields.$refField.options"; + } + + /** @var ?string[] $optionList */ + $optionList = $path ? + $this->metadata->get($path) : + $fieldDefs->getParam('options'); + + return $optionList; + } +} diff --git a/composer.lock b/composer.lock index fdfd6cce53..24b9428287 100644 --- a/composer.lock +++ b/composer.lock @@ -9909,9 +9909,9 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "laminas/laminas-ldap": 20, "laminas/laminas-mail": 20, "laminas/laminas-mime": 20, + "laminas/laminas-ldap": 20, "zordius/lightncandy": 20 }, "prefer-stable": false, @@ -9930,6 +9930,6 @@ "ext-pdo": "*", "ext-ctype": "*" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index 73efeaff6e..18782bcdb2 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -1120,6 +1120,10 @@ "type": "boolean", "description": "Disables the ability perform mass-update on the field. As of v8.4." }, + "apiSpecDisabled": { + "type": "boolean", + "description": "Disable field definitions in API specification. As of v9.3." + }, "isPersonalData": { "type": "boolean", "description": "Whether the field may contain personal data."