├── .husky └── pre-commit ├── LICENSE.md ├── composer.json └── src ├── Constants.php ├── QueryApi.php ├── config.php ├── console └── controllers │ ├── DefaultController.php │ └── TypescriptController.php ├── controllers ├── DefaultController.php ├── SchemaController.php └── TokenController.php ├── enums └── AssetMode.php ├── events ├── RegisterElementTypesEvent.php ├── RegisterFieldTransformersEvent.php └── RegisterTypeDefinitionEvent.php ├── helpers ├── AssetHelper.php ├── Permissions.php ├── Typescript.php └── Utils.php ├── icon-mask.svg ├── icon.svg ├── migrations └── Install.php ├── models ├── QueryApiSchema.php ├── QueryApiToken.php ├── RegisterElementType.php ├── RegisterTypeDefinition.php └── Settings.php ├── records ├── SchemaRecord.php └── TokenRecord.php ├── resources ├── QueryApiAsset.php └── src │ ├── script.js │ └── style.css ├── services ├── CacheService.php ├── ElementQueryService.php ├── JsonTransformerService.php ├── SchemaService.php ├── TokenService.php └── TypescriptService.php ├── templates ├── _components │ └── checkboxList │ │ └── checkboxList.twig ├── schemas │ ├── _edit.twig │ └── _index.twig └── tokens │ ├── _edit.twig │ └── _index.twig ├── transformers ├── AddressTransformer.php ├── AssetTransformer.php ├── BaseTransformer.php ├── CategoryTransformer.php ├── EntryTransformer.php ├── TagTransformer.php └── UserTransformer.php └── twigextensions └── AuthHelper.php /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run create-zod-schema dev 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The Craft License 2 | 3 | Copyright © Samuel Reichör 4 | 5 | Permission is hereby granted to any person obtaining a copy of this software 6 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 11 | included in all copies or substantial portions of the Software. 12 | 13 | 2. **Don’t use the same license on more than one project.** Each licensed copy 14 | of the Software shall be actively installed in no more than one production 15 | environment at a time. 16 | 17 | 3. **Don’t mess with the licensing features.** Software features related to 18 | licensing shall not be altered or circumvented in any way, including (but 19 | not limited to) license validation, payment prompts, feature restrictions, 20 | and update eligibility. 21 | 22 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 23 | prompt, reminder, or other message indicating that a payment is owed. 24 | 25 | 5. **Follow the law.** All use of the Software shall not violate any applicable 26 | law or regulation, nor infringe the rights of any other person or entity. 27 | 28 | Failure to comply with the foregoing conditions will automatically and 29 | immediately result in termination of the permission granted hereby. This 30 | license does not include any right to receive updates to the Software or 31 | technical support. Licensees bear all risk related to the quality and 32 | performance of the Software and any modifications made or obtained to it, 33 | including liability for actual and consequential harm, such as loss or 34 | corruption of data, and any necessary service, repair, or correction. 35 | 36 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 37 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 38 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 39 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 40 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 41 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 42 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "samuelreichor/craft-query-api", 3 | "description": "Speeds up development in headless mode with an API that allows querying data using URL parameters.", 4 | "type": "craft-plugin", 5 | "license": "proprietary", 6 | "version": "3.1.1", 7 | "support": { 8 | "email": "samuelreichor@gmail.com", 9 | "issues": "https://github.com/samuelreichor/craft-query-api/issues?state=open", 10 | "source": "https://github.com/samuelreichor/craft-query-api" 11 | }, 12 | "require": { 13 | "php": ">=8.2", 14 | "craftcms/cms": "^5.7.0" 15 | }, 16 | "require-dev": { 17 | "craftcms/ecs": "dev-main", 18 | "craftcms/phpstan": "dev-main" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "samuelreichoer\\queryapi\\": "src/" 23 | } 24 | }, 25 | "extra": { 26 | "handle": "query-api", 27 | "name": "Query API", 28 | "developer": "Samuel Reichoer", 29 | "documentationUrl": "https://samuelreichor.at/libraries/craft-query-api", 30 | "class": "samuelreichoer\\queryapi\\QueryApi" 31 | }, 32 | "scripts": { 33 | "check-cs": "ecs check --ansi", 34 | "fix-cs": "ecs check --ansi --fix", 35 | "phpstan": "phpstan --memory-limit=1G" 36 | }, 37 | "config": { 38 | "sort-packages": true, 39 | "optimize-autoloader": true, 40 | "platform": { 41 | "php": "8.2" 42 | }, 43 | "allow-plugins": { 44 | "yiisoft/yii2-composer": true, 45 | "craftcms/plugin-installer": true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Constants.php: -------------------------------------------------------------------------------- 1 | 30 | * @copyright Samuel Reichoer 31 | * @license MIT 32 | * 33 | * @property SchemaService $schema 34 | * @property TokenService $token 35 | * @property CacheService $cache 36 | * @property ElementQueryService $query 37 | * @property TypescriptService $typescript 38 | */ 39 | class QueryApi extends Plugin 40 | { 41 | public string $schemaVersion = '1.0.0'; 42 | public bool $hasCpSection = true; 43 | 44 | /** 45 | * @var ?QueryApi 46 | */ 47 | public static ?QueryApi $plugin; 48 | 49 | public static function config(): array 50 | { 51 | return [ 52 | 'components' => [ 53 | 'schema' => new SchemaService(), 54 | 'token' => new TokenService(), 55 | 'cache' => new CacheService(), 56 | 'query' => new ElementQueryService(), 57 | 'typescript' => new TypescriptService(), 58 | ], 59 | ]; 60 | } 61 | 62 | public function init(): void 63 | { 64 | parent::init(); 65 | self::$plugin = $this; 66 | 67 | $this->_initLogger(); 68 | $this->_registerConfigListeners(); 69 | $this->_registerClearCaches(); 70 | 71 | if (Craft::$app->getRequest()->getIsCpRequest()) { 72 | $this->_registerCpRoutes(); 73 | $this->_registerCpTwigExtensions(); 74 | $this->_registerPermissions(); 75 | } 76 | 77 | if (Craft::$app->getRequest()->getIsSiteRequest()) { 78 | $this->_registerSiteRoutes(); 79 | } 80 | } 81 | 82 | /** 83 | * @throws Throwable 84 | */ 85 | public function getCpNavItem(): ?array 86 | { 87 | $item = parent::getCpNavItem(); 88 | $subNavs = []; 89 | $isAllowedAdminChanges = Craft::$app->getConfig()->getGeneral()->allowAdminChanges; 90 | $currentUser = Craft::$app->getUser()->getIdentity(); 91 | if ($currentUser->can(Constants::EDIT_SCHEMAS) && $isAllowedAdminChanges) { 92 | $subNavs['schemas'] = [ 93 | 'label' => 'Schemas', 94 | 'url' => 'query-api/schemas', 95 | ]; 96 | } 97 | 98 | if ($currentUser->can(Constants::EDIT_TOKENS)) { 99 | $subNavs['tokens'] = [ 100 | 'label' => 'Tokens', 101 | 'url' => 'query-api/tokens', 102 | ]; 103 | } 104 | 105 | if (empty($subNavs)) { 106 | return null; 107 | } 108 | 109 | if (count($subNavs) <= 1) { 110 | return array_merge($item, [ 111 | 'subnav' => [], 112 | ]); 113 | } 114 | 115 | return array_merge($item, [ 116 | 'subnav' => $subNavs, 117 | ]); 118 | } 119 | 120 | protected function createSettingsModel(): ?Model 121 | { 122 | return new Settings(); 123 | } 124 | 125 | private function _initLogger(): void 126 | { 127 | $logFileTarget = new FileTarget([ 128 | 'logFile' => '@storage/logs/queryApi.log', 129 | 'maxLogFiles' => 10, 130 | 'categories' => ['queryApi'], 131 | 'logVars' => [], 132 | ]); 133 | Craft::getLogger()->dispatcher->targets[] = $logFileTarget; 134 | } 135 | 136 | private function _registerPermissions(): void 137 | { 138 | Event::on( 139 | UserPermissions::class, 140 | UserPermissions::EVENT_REGISTER_PERMISSIONS, 141 | function(RegisterUserPermissionsEvent $event) { 142 | $event->permissions[] = [ 143 | 'heading' => 'Query API', 144 | 'permissions' => [ 145 | Constants::EDIT_SCHEMAS => [ 146 | 'label' => 'Manage Schemas', 147 | ], 148 | Constants::EDIT_TOKENS => [ 149 | 'label' => 'Manage Tokens', 150 | ], 151 | ], 152 | ]; 153 | } 154 | ); 155 | } 156 | 157 | private function _registerSiteRoutes(): void 158 | { 159 | Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_SITE_URL_RULES, 160 | function(RegisterUrlRulesEvent $event) { 161 | $event->rules = array_merge($event->rules, [ 162 | '//api/queryApi/customQuery' => 'query-api/default/get-custom-query-result', 163 | '//api/queryApi/allRoutes' => 'query-api/default/get-all-routes', 164 | '//api/queryApi/allRoutes/' => 'query-api/default/get-all-routes', 165 | ]); 166 | }); 167 | } 168 | 169 | private function _registerCpRoutes(): void 170 | { 171 | Event::on(UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, function(RegisterUrlRulesEvent $event) { 172 | $urlRules = []; 173 | 174 | $isAllowedAdminChanges = Craft::$app->getConfig()->getGeneral()->allowAdminChanges; 175 | $currentUser = Craft::$app->getUser()->getIdentity(); 176 | 177 | // Cp request but no valid sessionId. 178 | if (!$currentUser) { 179 | return; 180 | } 181 | 182 | $canEditSchemas = $currentUser->can(Constants::EDIT_SCHEMAS) && $isAllowedAdminChanges; 183 | $canEditTokens = $currentUser->can(Constants::EDIT_TOKENS); 184 | 185 | if ($canEditSchemas) { 186 | $urlRules['query-api'] = ['template' => 'query-api/schemas/_index.twig']; 187 | $urlRules['query-api/schemas'] = ['template' => 'query-api/schemas/_index.twig']; 188 | $urlRules['query-api/schemas/new'] = 'query-api/schema/edit-schema'; 189 | $urlRules['query-api/schemas/'] = 'query-api/schema/edit-schema'; 190 | } 191 | 192 | if ($canEditTokens) { 193 | $urlRules['query-api/tokens'] = ['template' => 'query-api/tokens/_index.twig']; 194 | $urlRules['query-api/tokens/new'] = 'query-api/token/edit-token'; 195 | $urlRules['query-api/tokens/'] = 'query-api/token/edit-token'; 196 | 197 | if (!$canEditSchemas) { 198 | $urlRules['query-api'] = ['template' => 'query-api/tokens/_index.twig']; 199 | } 200 | } 201 | 202 | $event->rules = array_merge($event->rules, $urlRules); 203 | }); 204 | } 205 | 206 | private function _registerClearCaches(): void 207 | { 208 | Event::on(ClearCaches::class, ClearCaches::EVENT_REGISTER_CACHE_OPTIONS, function(RegisterCacheOptionsEvent $event) { 209 | $event->options[] = [ 210 | 'key' => 'query-api', 211 | 'label' => Craft::t('query-api', 'Query API data cache'), 212 | 'action' => [self::$plugin->cache, 'invalidateCaches'], 213 | ]; 214 | }); 215 | } 216 | 217 | private function _registerCpTwigExtensions(): void 218 | { 219 | Craft::$app->view->registerTwigExtension(new AuthHelper()); 220 | } 221 | 222 | private function _registerConfigListeners(): void 223 | { 224 | Craft::$app->getProjectConfig() 225 | ->onAdd(Constants::PATH_SCHEMAS . '.{uid}', $this->_proxy('schema', 'handleChangedSchema')) 226 | ->onUpdate(Constants::PATH_SCHEMAS . '.{uid}', $this->_proxy('schema', 'handleChangedSchema')) 227 | ->onRemove(Constants::PATH_SCHEMAS . '.{uid}', $this->_proxy('schema', 'handleDeletedSchema')); 228 | } 229 | 230 | /** 231 | * Returns a proxy function for calling a component method, based on its ID. 232 | * 233 | * The component won’t be fetched until the method is called, avoiding unnecessary component instantiation, and ensuring the correct component 234 | * is called if it happens to get swapped out (e.g. for a test). 235 | * 236 | * @param string $id The component ID 237 | * @param string $method The method name 238 | * @return callable 239 | */ 240 | private function _proxy(string $id, string $method): callable 241 | { 242 | return function() use ($id, $method) { 243 | return $this->get($id)->$method(...func_get_args()); 244 | }; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | [ 19 | // Defines the cache duration. Defaults to the cache duration defined in your general.php. 20 | // 'cacheDuration' => 69420, 21 | // 22 | // Define field classes that should be excluded from the response. 23 | // 'excludeFieldClasses' => ['nystudio107\seomatic\fields\SeoSettings']*/ 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /src/console/controllers/DefaultController.php: -------------------------------------------------------------------------------- 1 | stdout("Creating public schema...\n"); 20 | $schema = new QueryApiSchema(); 21 | $schema->name = 'Public Schema'; 22 | $schema->scope = [ 23 | "sites.*:read", 24 | "sections.*:read", 25 | "usergroups.*:read", 26 | "volumes.*:read", 27 | "addresses.*:read", 28 | ]; 29 | 30 | if (!QueryApi::getInstance()->schema->saveSchema($schema)) { 31 | $this->stderr("Failed to save schema. Validation errors:\n"); 32 | 33 | foreach ($schema->getErrors() as $attribute => $errors) { 34 | foreach ($errors as $error) { 35 | $this->stderr(" - [$attribute] $error\n"); 36 | } 37 | } 38 | return ExitCode::UNSPECIFIED_ERROR; 39 | } 40 | 41 | // Invalidate query API caches 42 | QueryApi::getInstance()->cache->invalidateCaches(); 43 | 44 | $this->stdout("Successfully created public schema.\n"); 45 | return ExitCode::OK; 46 | } 47 | 48 | /** 49 | * @throws Exception 50 | */ 51 | public function actionCreatePublicToken(): int 52 | { 53 | $this->actionCreatePublicSchema(); 54 | $schema = QueryApi::getInstance()->schema->getSchemaByName('Public Schema'); 55 | 56 | $tokenService = QueryApi::getInstance()->token; 57 | 58 | $token = new QueryApiToken(); 59 | $token->name = 'Public Token'; 60 | $token->accessToken = $tokenService->generateToken(); 61 | $token->enabled = true; 62 | $token->schemaId = $schema->id; 63 | 64 | if (!$tokenService->saveToken($token)) { 65 | $this->stderr("Failed to save token. Validation errors:\n"); 66 | 67 | foreach ($schema->getErrors() as $attribute => $errors) { 68 | foreach ($errors as $error) { 69 | $this->stderr(" - [$attribute] $error\n"); 70 | } 71 | } 72 | return ExitCode::UNSPECIFIED_ERROR; 73 | } 74 | 75 | $this->stdout("Successfully created public token.\n"); 76 | $this->stdout("Your Token is: {$token->accessToken}\n"); 77 | 78 | return ExitCode::OK; 79 | } 80 | 81 | public function actionClearCaches(): int 82 | { 83 | QueryApi::getInstance()->cache->invalidateCaches(); 84 | return ExitCode::OK; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/console/controllers/TypescriptController.php: -------------------------------------------------------------------------------- 1 | output ?? '@root/queryApiTypes.ts'; 27 | $types = QueryApi::getInstance()->typescript->getTypes(); 28 | $outputPath = Craft::getAlias($output); 29 | 30 | $dir = dirname($outputPath); 31 | if (!is_dir($dir)) { 32 | mkdir($dir, 0775, true); 33 | } 34 | 35 | file_put_contents($outputPath, $types); 36 | 37 | $this->stdout("✔ TypeScript file written to: $outputPath\n", Console::FG_GREEN); 38 | 39 | return ExitCode::OK; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/controllers/DefaultController.php: -------------------------------------------------------------------------------- 1 | _setCorsHeaders(); 34 | $request = $this->request; 35 | 36 | if ($request->getIsOptions()) { 37 | // This is just a preflight request, no need to run the actual query yet 38 | $this->response->format = Response::FORMAT_RAW; 39 | $this->response->data = ''; 40 | return $this->response; 41 | } 42 | 43 | $schema = $this->_getActiveSchema(); 44 | Permissions::canQuerySites($schema); 45 | 46 | $params = $request->getQueryParams(); 47 | 48 | // Early return when no params are available. Min is one()/all() 49 | if (count($params) < 1) { 50 | return $this->asJson([]); 51 | } 52 | 53 | $queryOne = isset($params['one']) && $params['one'] === '1'; 54 | $queryAll = isset($params['all']) && $params['all'] === '1'; 55 | 56 | if (!$queryAll && !$queryOne) { 57 | throw new BadRequestHttpException('No query was executed. This is usually because .one() or .all() is missing in the query'); 58 | } 59 | 60 | // Get the elementType of the query 61 | $elementType = 'entries'; 62 | if (isset($params['elementType']) && $params['elementType']) { 63 | $elementType = $params['elementType']; 64 | unset($params['elementType']); 65 | } 66 | 67 | // Transform string of field handles to array 68 | $predefinedFieldHandleArr = []; 69 | if (isset($params['fields']) && $params['fields']) { 70 | $predefinedFieldHandleArr = explode(',', $params['fields']); 71 | unset($params['fields']); 72 | } 73 | 74 | // Transform all other comma seperated strings to array 75 | foreach ($params as $key => $value) { 76 | if (is_string($value) && str_contains($value, ',')) { 77 | $params[$key] = explode(',', $value); 78 | } 79 | } 80 | 81 | // Return cached query data 82 | $cacheKey = Constants::CACHE_TAG_GlOBAL . $elementType . '_' . Utils::generateCacheKey([ 83 | 'schema' => $schema->uid, 84 | 'params' => $params, 85 | ]); 86 | 87 | if (($result = Craft::$app->getCache()->get($cacheKey)) && $this->getIsCacheableRequest($request)) { 88 | return $result; 89 | } 90 | 91 | // Set cache duration of config and fallback to general craft cache duration 92 | $duration = QueryApi::getInstance()->cache->getCacheDuration(); 93 | 94 | Craft::$app->getElements()->startCollectingCacheInfo(); 95 | 96 | // Instantiate the Query Service and handle query execution 97 | $queryService = new ElementQueryService(); 98 | $result = $queryService->executeQuery($elementType, $params, $schema); 99 | 100 | // Instantiate the Transform Service and handle transforming different elementTypes 101 | $transformerService = new JsonTransformerService($queryService); 102 | $transformedData = $transformerService->executeTransform($result, $predefinedFieldHandleArr); 103 | 104 | $finalResult = $this->asJson($queryOne ? ($transformedData[0] ?? null) : $transformedData); 105 | 106 | [$craftDependency] = Craft::$app->getElements()->stopCollectingCacheInfo(); 107 | 108 | $tags = $craftDependency instanceof TagDependency ? $craftDependency->tags : []; 109 | $tags[] = Constants::CACHE_TAG_GlOBAL; 110 | $combinedDependency = new TagDependency(['tags' => $tags]); 111 | 112 | Craft::$app->getCache()->set( 113 | $cacheKey, 114 | $finalResult, 115 | $duration, 116 | $combinedDependency 117 | ); 118 | 119 | return $finalResult; 120 | } 121 | 122 | /** 123 | * @throws Exception 124 | */ 125 | public function actionGetAllRoutes($siteIds = null): Response 126 | { 127 | $this->_setCorsHeaders(); 128 | $request = $this->request; 129 | 130 | if ($request->getIsOptions()) { 131 | // This is just a preflight request, no need to run the actual query yet 132 | $this->response->format = Response::FORMAT_RAW; 133 | $this->response->data = ''; 134 | return $this->response; 135 | } 136 | 137 | $schema = $this->_getActiveSchema(); 138 | Permissions::canQuerySites($schema); 139 | 140 | $validSiteIds = $this->_getValidSiteIds($siteIds); 141 | 142 | $cacheKey = Constants::CACHE_TAG_GlOBAL . 'get-all-routes_' . Utils::generateCacheKey([ 143 | 'schema' => $schema->uid, 144 | 'siteIds' => $validSiteIds, 145 | ]); 146 | 147 | if ($result = Craft::$app->getCache()->get($cacheKey)) { 148 | return $result; 149 | } 150 | 151 | if (!Permissions::canQueryAllSites($schema)) { 152 | foreach ($validSiteIds as $siteId) { 153 | $site = Craft::$app->getSites()->getSiteById($siteId); 154 | if (!$schema->has("sites.{$site->uid}:read")) { 155 | throw new ForbiddenHttpException("Schema doesn't have access to site with handle: {$site->handle}"); 156 | } 157 | } 158 | } 159 | 160 | $allSectionIds = Craft::$app->entries->getAllSectionIds(); 161 | 162 | $duration = QueryApi::getInstance()->cache->getCacheDuration(); 163 | Craft::$app->getElements()->startCollectingCacheInfo(); 164 | 165 | $allUrls = []; 166 | $allEntries = Entry::find() 167 | ->siteId($validSiteIds) 168 | ->status('live') 169 | ->sectionId($allSectionIds) 170 | ->all(); 171 | 172 | foreach ($allEntries as $entry) { 173 | $allUrls[] = $entry->getUrl(); 174 | } 175 | 176 | $finalResult = $this->asJson($allUrls); 177 | 178 | [$craftDependency] = Craft::$app->getElements()->stopCollectingCacheInfo(); 179 | $tags = $craftDependency instanceof TagDependency ? $craftDependency->tags : []; 180 | $tags[] = Constants::CACHE_TAG_GlOBAL; 181 | $combinedDependency = new TagDependency(['tags' => $tags]); 182 | 183 | Craft::$app->getCache()->set( 184 | $cacheKey, 185 | $finalResult, 186 | $duration, 187 | $combinedDependency 188 | ); 189 | 190 | return $finalResult; 191 | } 192 | 193 | private function _setCorsHeaders(): void 194 | { 195 | $headers = $this->response->getHeaders(); 196 | $headers->setDefault('Access-Control-Allow-Credentials', 'true'); 197 | $headers->setDefault('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Craft-Authorization, X-Craft-Token'); 198 | 199 | $corsFilter = Craft::$app->getBehavior('corsFilter'); 200 | $allowedOrigins = $corsFilter->cors['Origin'] ?? []; 201 | if (is_array($allowedOrigins)) { 202 | if (($origins = $this->request->getOrigin()) !== null) { 203 | $origins = ArrayHelper::filterEmptyStringsFromArray(array_map('trim', explode(',', $origins))); 204 | foreach ($origins as $origin) { 205 | if (in_array($origin, $allowedOrigins)) { 206 | $headers->setDefault('Access-Control-Allow-Origin', $origin); 207 | break; 208 | } 209 | } 210 | } 211 | } else { 212 | $headers->setDefault('Access-Control-Allow-Origin', '*'); 213 | } 214 | } 215 | 216 | /** 217 | * @throws BadRequestHttpException 218 | * @throws UnauthorizedHttpException 219 | */ 220 | private function _getActiveSchema(): QueryApiSchema 221 | { 222 | $bearerToken = $this->request->getBearerToken(); 223 | 224 | if (!$bearerToken) { 225 | throw new BadRequestHttpException('Missing Authorization header.'); 226 | } 227 | 228 | $token = QueryApi::getInstance()->token->getTokenByAccessToken($bearerToken); 229 | 230 | if (!$token->getIsValid()) { 231 | throw new UnauthorizedHttpException('Invalid or inactive access token.'); 232 | } 233 | 234 | return $token->getSchema(); 235 | } 236 | 237 | /** 238 | * @throws BadRequestHttpException 239 | */ 240 | private function _getValidSiteIds(?string $rawSiteIds) 241 | { 242 | $allSiteIds = Craft::$app->sites->getAllSiteIds(); 243 | if ($rawSiteIds === null) { 244 | return $allSiteIds; 245 | } 246 | 247 | // decode url needed when using decoded arrays 248 | $decodedSiteId = urldecode($rawSiteIds); 249 | 250 | // if it is a json array “[1,2]“ 251 | $siteIds = json_decode($decodedSiteId, true); 252 | 253 | // If json decode is not working, it is a primitive type 254 | if (!is_array($siteIds)) { 255 | $siteIds = [$rawSiteIds]; 256 | } 257 | 258 | // Check if Site Ids are valid 259 | foreach ($siteIds as $id) { 260 | if (!in_array($id, $allSiteIds)) { 261 | throw new BadRequestHttpException('Invalid SiteId: ' . $id); 262 | } 263 | } 264 | 265 | return $siteIds; 266 | } 267 | 268 | private function getIsCacheableRequest(Request $request): bool 269 | { 270 | if ($request->getIsPreview() || $request->getIsLivePreview()) { 271 | return false; 272 | } 273 | 274 | return true; 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/controllers/SchemaController.php: -------------------------------------------------------------------------------- 1 | requirePermission(Constants::EDIT_SCHEMAS); 29 | $general = Craft::$app->getConfig()->getGeneral(); 30 | if (!$general->allowAdminChanges) { 31 | throw new ForbiddenHttpException('Unable to edit Query API schemas because admin changes are disabled in this environment.'); 32 | } 33 | 34 | if ($schema || $schemaId) { 35 | if (!$schema) { 36 | $schema = QueryApi::getInstance()->schema->getSchemaById($schemaId); 37 | } 38 | 39 | if (!$schema) { 40 | throw new NotFoundHttpException('Schema not found'); 41 | } 42 | 43 | $title = trim($schema->name) ?: Craft::t('app', 'Edit Query API Schema'); 44 | $usage = QueryApi::getInstance()->token->getSchemaUsageInTokens($schema->id); 45 | } else { 46 | $schema = new QueryApiSchema(); 47 | $title = trim($schema->name) ?: Craft::t('app', 'Create a new Query API Schema'); 48 | $usage = []; 49 | } 50 | 51 | return $this->renderTemplate('query-api/schemas/_edit.twig', compact( 52 | 'schema', 53 | 'title', 54 | 'usage', 55 | )); 56 | } 57 | 58 | /** 59 | * @return Response|null 60 | * @throws BadRequestHttpException 61 | * @throws ForbiddenHttpException 62 | * @throws NotFoundHttpException 63 | * @throws Exception 64 | */ 65 | public function actionSaveSchema(): ?Response 66 | { 67 | $this->requirePermission(Constants::EDIT_SCHEMAS); 68 | $this->requirePostRequest(); 69 | $this->requireElevatedSession(); 70 | 71 | $schemaService = QueryApi::getInstance()->schema; 72 | $schemaId = $this->request->getBodyParam('schemaId'); 73 | 74 | if ($schemaId) { 75 | $schema = $schemaService->getSchemaById($schemaId); 76 | 77 | if (!$schema) { 78 | throw new NotFoundHttpException('Schema not found'); 79 | } 80 | } else { 81 | $schema = new QueryApiSchema(); 82 | } 83 | 84 | $schema->name = $this->request->getBodyParam('name') ?? $schema->name; 85 | $schema->scope = $this->request->getBodyParam('permissions') ?? []; 86 | 87 | if (!$schemaService->saveSchema($schema)) { 88 | $this->setFailFlash(Craft::t('app', 'Couldn’t save schema.')); 89 | 90 | // Send the schema back to the template 91 | Craft::$app->getUrlManager()->setRouteParams([ 92 | 'schema' => $schema, 93 | ]); 94 | 95 | return null; 96 | } 97 | 98 | // Invalidate query API caches 99 | QueryApi::getInstance()->cache->invalidateCaches(); 100 | 101 | $this->setSuccessFlash(Craft::t('app', 'Schema saved.')); 102 | return $this->redirectToPostedUrl($schema); 103 | } 104 | 105 | /** 106 | * @return Response 107 | * @throws BadRequestHttpException 108 | * @throws MethodNotAllowedHttpException 109 | * @throws ForbiddenHttpException 110 | */ 111 | public function actionDeleteSchema(): Response 112 | { 113 | $this->requirePermission(Constants::EDIT_SCHEMAS); 114 | $this->requirePostRequest(); 115 | $this->requireAcceptsJson(); 116 | 117 | $schemaId = $this->request->getRequiredBodyParam('id'); 118 | 119 | QueryApi::getInstance()->schema->deleteSchemaById($schemaId); 120 | 121 | return $this->asSuccess(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/controllers/TokenController.php: -------------------------------------------------------------------------------- 1 | requirePermission(Constants::EDIT_TOKENS); 32 | 33 | $tokenService = QueryApi::getInstance()->token; 34 | $accessToken = null; 35 | 36 | if ($token || $tokenId) { 37 | if (!$token) { 38 | $token = $tokenService->getTokenById($tokenId); 39 | } 40 | 41 | if (!$token) { 42 | throw new NotFoundHttpException('Token not found'); 43 | } 44 | 45 | $title = trim($token->name) ?: Craft::t('app', 'Edit Query API Token'); 46 | } else { 47 | $token = new QueryApiToken(); 48 | $accessToken = $tokenService->generateToken(); 49 | $title = trim($token->name) ?: Craft::t('app', 'Create a new Query API token'); 50 | } 51 | 52 | $schemas = QueryApi::getInstance()->schema->getSchemas(); 53 | $schemaOptions = []; 54 | 55 | foreach ($schemas as $schema) { 56 | $schemaOptions[] = [ 57 | 'label' => $schema->name, 58 | 'value' => $schema->id, 59 | ]; 60 | } 61 | 62 | if ($token->id && !$token->schemaId && !empty($schemaOptions)) { 63 | // Add a blank option to the top so it's clear no schema is currently selected 64 | array_unshift($schemaOptions, [ 65 | 'label' => '', 66 | 'value' => '', 67 | ]); 68 | } 69 | 70 | return $this->renderTemplate('query-api/tokens/_edit.twig', compact( 71 | 'token', 72 | 'title', 73 | 'accessToken', 74 | 'schemaOptions' 75 | )); 76 | } 77 | 78 | /** 79 | * @return Response|null 80 | * @throws BadRequestHttpException 81 | * @throws ForbiddenHttpException 82 | * @throws NotFoundHttpException 83 | * @throws Exception 84 | */ 85 | public function actionSaveToken(): ?Response 86 | { 87 | $this->requirePermission(Constants::EDIT_TOKENS); 88 | $this->requirePostRequest(); 89 | $this->requireElevatedSession(); 90 | 91 | $tokenService = QueryApi::getInstance()->token; 92 | $tokenId = $this->request->getBodyParam('tokenId'); 93 | 94 | if ($tokenId) { 95 | $token = $tokenService->getTokenById($tokenId); 96 | 97 | if (!$token) { 98 | throw new NotFoundHttpException('Token not found'); 99 | } 100 | } else { 101 | $token = new QueryApiToken(); 102 | } 103 | 104 | $token->name = $this->request->getBodyParam('name') ?? $token->name; 105 | $token->accessToken = $this->request->getBodyParam('accessToken') ?? $token->accessToken; 106 | $token->enabled = (bool)$this->request->getRequiredBodyParam('enabled'); 107 | $token->schemaId = $this->request->getBodyParam('schema'); 108 | 109 | if (!$tokenService->saveToken($token)) { 110 | $this->setFailFlash(Craft::t('app', 'Couldn’t save token.')); 111 | 112 | // Send the schema back to the template 113 | Craft::$app->getUrlManager()->setRouteParams([ 114 | 'token' => $token, 115 | ]); 116 | 117 | return null; 118 | } 119 | 120 | $this->setSuccessFlash(Craft::t('app', 'Token saved.')); 121 | return $this->redirectToPostedUrl($token); 122 | } 123 | 124 | /** 125 | * @return Response 126 | * @throws BadRequestHttpException 127 | * @throws ForbiddenHttpException 128 | * @throws MethodNotAllowedHttpException 129 | */ 130 | public function actionDeleteToken(): Response 131 | { 132 | $this->requirePermission(Constants::EDIT_TOKENS); 133 | $this->requirePostRequest(); 134 | $this->requireAcceptsJson(); 135 | 136 | $tokenId = $this->request->getRequiredBodyParam('id'); 137 | 138 | QueryApi::getInstance()->token->deleteTokenById($tokenId); 139 | 140 | return $this->asSuccess(); 141 | } 142 | 143 | /** 144 | * @return Response 145 | * @throws BadRequestHttpException 146 | * @throws MethodNotAllowedHttpException 147 | * @throws ForbiddenHttpException 148 | */ 149 | public function actionFetchToken(): Response 150 | { 151 | $this->requirePermission(Constants::EDIT_TOKENS); 152 | $this->requirePostRequest(); 153 | $this->requireAcceptsJson(); 154 | $this->requireElevatedSession(); 155 | 156 | $tokenUid = $this->request->getRequiredBodyParam('tokenUid'); 157 | 158 | try { 159 | $token = QueryApi::getInstance()->token->getTokenByUid($tokenUid); 160 | } catch (InvalidArgumentException) { 161 | throw new BadRequestHttpException('Invalid token UID.'); 162 | } 163 | 164 | return $this->asJson([ 165 | 'accessToken' => $token->accessToken, 166 | ]); 167 | } 168 | 169 | /** 170 | * @return Response 171 | * @throws MethodNotAllowedHttpException 172 | * @throws ForbiddenHttpException 173 | * @throws BadRequestHttpException 174 | * @throws Exception 175 | */ 176 | public function actionGenerateToken(): Response 177 | { 178 | $this->requirePermission(Constants::EDIT_TOKENS); 179 | $this->requirePostRequest(); 180 | $this->requireAcceptsJson(); 181 | 182 | return $this->asJson([ 183 | 'accessToken' => QueryAPI::getInstance()->token->generateToken(), 184 | ]); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/enums/AssetMode.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public array $transformers = []; 13 | } 14 | -------------------------------------------------------------------------------- /src/events/RegisterTypeDefinitionEvent.php: -------------------------------------------------------------------------------- 1 | getAllScopePairsForAction($action); 20 | } 21 | 22 | /** 23 | * @throws ForbiddenHttpException 24 | */ 25 | public static function canQuerySites(QueryApiSchema $schema): bool 26 | { 27 | $allowedEntities = self::extractAllowedEntitiesFromSchema('read', $schema); 28 | 29 | if (!isset($allowedEntities['sites'])) { 30 | throw new ForbiddenHttpException('Schema doesn’t have access to any site'); 31 | } 32 | 33 | return true; 34 | } 35 | 36 | public static function canQueryAllSites(QueryApiSchema $schema): bool 37 | { 38 | return $schema->has('sites.*:read'); 39 | } 40 | 41 | /** 42 | * @throws ForbiddenHttpException 43 | */ 44 | public static function canQueryEntries(QueryApiSchema $schema): bool 45 | { 46 | $allowedEntities = self::extractAllowedEntitiesFromSchema('read', $schema); 47 | 48 | if (!isset($allowedEntities['sections'])) { 49 | throw new ForbiddenHttpException('Schema doesn’t have access to any section'); 50 | } 51 | 52 | return true; 53 | } 54 | 55 | public static function canQueryAllEntries(QueryApiSchema $schema): bool 56 | { 57 | return $schema->has('sections.*:read'); 58 | } 59 | 60 | /** 61 | * @throws ForbiddenHttpException 62 | */ 63 | public static function canQueryUsers(QueryApiSchema $schema): bool 64 | { 65 | $allowedEntities = self::extractAllowedEntitiesFromSchema('read', $schema); 66 | 67 | if (!isset($allowedEntities['usergroups'])) { 68 | throw new ForbiddenHttpException('Schema doesn’t have access to any user group'); 69 | } 70 | return true; 71 | } 72 | 73 | /** 74 | * @throws ForbiddenHttpException 75 | */ 76 | public static function canQueryAssets(QueryApiSchema $schema): bool 77 | { 78 | $allowedEntities = self::extractAllowedEntitiesFromSchema('read', $schema); 79 | 80 | if (!isset($allowedEntities['volumes'])) { 81 | throw new ForbiddenHttpException('Schema doesn’t have access to any volume'); 82 | } 83 | return true; 84 | } 85 | 86 | /** 87 | * @throws ForbiddenHttpException 88 | */ 89 | public static function canQueryAddresses(QueryApiSchema $schema): bool 90 | { 91 | $allowedEntities = self::extractAllowedEntitiesFromSchema('read', $schema); 92 | 93 | if (!isset($allowedEntities['addresses'])) { 94 | throw new ForbiddenHttpException('Schema doesn’t have access to addresses'); 95 | } 96 | return true; 97 | } 98 | 99 | /** 100 | * @throws ForbiddenHttpException 101 | */ 102 | public static function canQueryElement(string $elementType, QueryApiSchema $schema): void 103 | { 104 | $checkPermissions = [ 105 | 'assets' => fn($schema) => self::canQueryAssets($schema), 106 | 'entries' => fn($schema) => self::canQueryEntries($schema), 107 | 'users' => fn($schema) => self::canQueryUsers($schema), 108 | 'addresses' => fn($schema) => self::canQueryAddresses($schema), 109 | ]; 110 | 111 | if (isset($checkPermissions[$elementType])) { 112 | $checkPermissions[$elementType]($schema); 113 | } else { 114 | if (!self::canQueryCustomElement($elementType, $schema)) { 115 | throw new ForbiddenHttpException('Schema doesn’t have access to element with handle: ' . $elementType); 116 | } 117 | } 118 | } 119 | 120 | public static function canQueryAllElement(string $elementType, QueryApiSchema $schema): bool 121 | { 122 | $checkPermissions = [ 123 | 'assets' => fn($schema) => $schema->has('volumes.*:read'), 124 | 'entries' => fn($schema) => $schema->has('sections.*:read') && $schema->has('sites.*:read'), 125 | 'users' => fn($schema) => $schema->has('usergroups.*:read'), 126 | 'addresses' => fn($schema) => $schema->has('addresses.*:read'), 127 | ]; 128 | 129 | if (isset($checkPermissions[$elementType])) { 130 | return $checkPermissions[$elementType]($schema); 131 | } 132 | 133 | return self::canQueryCustomElement($elementType, $schema); 134 | } 135 | 136 | public static function canQueryCustomElement(string $elementType, QueryApiSchema $schema): bool 137 | { 138 | return $schema->has($elementType . ':read'); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/helpers/Typescript.php: -------------------------------------------------------------------------------- 1 | $value) { 28 | $quotedKey = self::quoteKeyIfNecessary($key); 29 | 30 | if (is_array($value)) { 31 | $nested = self::buildTsType($value, '', $kind, $indent + 2, true); 32 | $lines[] = $innerIndent . "{$quotedKey}: {$nested}"; 33 | } else { 34 | $lines[] = $innerIndent . "{$quotedKey}: {$value}"; 35 | } 36 | } 37 | 38 | $lines[] = $baseIndent . '}'; 39 | 40 | return implode("\n", $lines); 41 | } 42 | 43 | protected static function quoteKeyIfNecessary(string $key): string 44 | { 45 | // If it matches a valid identifier (e.g. title, imageUrl), leave it unquoted 46 | if (preg_match('/^[a-zA-Z_$][a-zA-Z0-9_$]*$/', $key)) { 47 | return $key; 48 | } 49 | 50 | // If it contains special characters, numbers first, spaces, etc., wrap it 51 | return "'" . $key . "'"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/Utils.php: -------------------------------------------------------------------------------- 1 | plugins->getPlugin($pluginHandle); 25 | 26 | if ($plugin !== null && $plugin->isInstalled) { 27 | return true; 28 | } 29 | 30 | return false; 31 | } 32 | 33 | /** 34 | * Generates a cache key based on the given parameters. 35 | * 36 | * @param array $params 37 | * @return string 38 | */ 39 | public static function generateCacheKey(array $params): string 40 | { 41 | $normalizedParams = self::recursiveKsort($params); 42 | $serializedParams = json_encode($normalizedParams); 43 | return hash('sha256', $serializedParams); 44 | } 45 | 46 | /** 47 | * Returns the full uri based on a given uri 48 | * 49 | * @param string|null $url 50 | * @return string|null 51 | */ 52 | public static function getFullUriFromUrl(?string $url): string|null 53 | { 54 | if (!$url) { 55 | return null; 56 | } 57 | 58 | $pattern = '/https?:\/\/[^\/]+(\/.*)/'; 59 | 60 | if (preg_match($pattern, $url, $matches)) { 61 | return $matches[1]; 62 | } 63 | 64 | return '/'; 65 | } 66 | 67 | public static function isSingleRelationField($field): bool 68 | { 69 | return (property_exists($field, 'maxEntries') && $field->maxEntries === 1) 70 | || (property_exists($field, 'maxRelations') && $field->maxRelations === 1) 71 | || (property_exists($field, 'maxAddresses') && $field->maxAddresses === 1) 72 | || (get_class($field) === PhotoField::class); 73 | } 74 | 75 | public static function isArrayField($field): bool 76 | { 77 | return (property_exists($field, 'maxEntries') && $field->maxEntries !== 1) 78 | || (property_exists($field, 'maxRelations') && $field->maxRelations !== 1) 79 | || (property_exists($field, 'maxAddresses') && $field->maxAddresses !== 1) 80 | || (get_class($field) === Checkboxes::class) 81 | || (get_class($field) === MultiSelect::class) 82 | || (get_class($field) === Table::class); 83 | } 84 | 85 | public static function isRequiredField($field): bool 86 | { 87 | $c = get_class($field); 88 | 89 | // If option field has default option -> set it to required as there is always a valid value 90 | if ($c === Dropdown::class || $c === RadioButtons::class || $c === Checkboxes::class || $c === MultiSelect::class || $c === ButtonGroup::class) { 91 | if (property_exists($field, 'options')) { 92 | foreach ($field->options as $option) { 93 | if ($option["default"] === '1') { 94 | return true; 95 | } 96 | } 97 | } 98 | } 99 | return (property_exists($field, 'required') && $field->required); 100 | } 101 | 102 | /** 103 | * Recursively sorts an array by its keys. 104 | * 105 | * @param array $array 106 | * @return array 107 | */ 108 | private static function recursiveKsort(array $array): array 109 | { 110 | foreach ($array as &$value) { 111 | if (is_array($value)) { 112 | $value = self::recursiveKsort($value); 113 | } 114 | } 115 | ksort($array); 116 | return $array; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/migrations/Install.php: -------------------------------------------------------------------------------- 1 | driver = Craft::$app->getConfig()->getDb()->driver; 27 | if ($this->createTables()) { 28 | $this->addForeignKeys(); 29 | // Refresh the db schema caches 30 | Craft::$app->db->schema->refresh(); 31 | } 32 | 33 | return true; 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function safeDown() 40 | { 41 | $this->driver = Craft::$app->getConfig()->getDb()->driver; 42 | $this->removeTables(); 43 | 44 | return true; 45 | } 46 | 47 | /** 48 | * @return bool 49 | * @throws Exception 50 | */ 51 | protected function createTables(): bool 52 | { 53 | $tablesCreated = false; 54 | 55 | $tableSchema = Craft::$app->db->schema->getTableSchema(Constants::TABLE_SCHEMAS); 56 | if ($tableSchema === null) { 57 | $tablesCreated = true; 58 | $this->createTable( 59 | Constants::TABLE_SCHEMAS, 60 | [ 61 | 'id' => $this->primaryKey(), 62 | 'name' => $this->string(), 63 | 'scope' => $this->json(), 64 | 'dateCreated' => $this->dateTime()->notNull(), 65 | 'dateUpdated' => $this->dateTime()->notNull(), 66 | 'uid' => $this->uid(), 67 | ] 68 | ); 69 | } 70 | 71 | $tableSchema = Craft::$app->db->schema->getTableSchema(Constants::TABLE_TOKENS); 72 | if ($tableSchema === null) { 73 | $tablesCreated = true; 74 | $this->createTable( 75 | Constants::TABLE_TOKENS, 76 | [ 77 | 'id' => $this->primaryKey(), 78 | 'name' => $this->string(), 79 | 'schemaId' => $this->string(), 80 | 'accessToken' => $this->string(), 81 | 'enabled' => $this->boolean(), 82 | 'dateCreated' => $this->dateTime()->notNull(), 83 | 'dateUpdated' => $this->dateTime()->notNull(), 84 | 'uid' => $this->uid(), 85 | ] 86 | ); 87 | } 88 | 89 | return $tablesCreated; 90 | } 91 | 92 | protected function removeTables(): void 93 | { 94 | $this->dropTableIfExists(Constants::TABLE_SCHEMAS); 95 | $this->dropTableIfExists(Constants::TABLE_TOKENS); 96 | } 97 | 98 | protected function addForeignKeys(): void 99 | { 100 | /* $this->addForeignKey( 101 | null, 102 | Constants::TABLE_SCHEMAS, 103 | 'id', 104 | '{{%elements}}', 105 | 'id', 106 | 'CASCADE', 107 | null 108 | );*/ 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/models/QueryApiSchema.php: -------------------------------------------------------------------------------- 1 | scope) && in_array($name, $this->scope, true); 50 | } 51 | 52 | /** 53 | * Return all scope pairs. 54 | * 55 | * @return array 56 | */ 57 | public function getAllScopePairs(): array 58 | { 59 | if (!empty($this->_cachedPairs)) { 60 | return $this->_cachedPairs; 61 | } 62 | foreach ((array)$this->scope as $permission) { 63 | if (preg_match('/:([\w-]+)$/', $permission, $matches)) { 64 | $action = $matches[1]; 65 | $permission = StringHelper::removeRight($permission, ':' . $action); 66 | $parts = explode('.', $permission); 67 | if (count($parts) === 2) { 68 | $this->_cachedPairs[$action][$parts[0]][] = $parts[1]; 69 | } elseif (count($parts) === 1) { 70 | $this->_cachedPairs[$action][$parts[0]] = true; 71 | } 72 | } 73 | } 74 | return $this->_cachedPairs; 75 | } 76 | 77 | /** 78 | * Return all scope pairs. 79 | * 80 | * @param string $action 81 | * @return array 82 | */ 83 | public function getAllScopePairsForAction(string $action = 'read'): array 84 | { 85 | $pairs = $this->getAllScopePairs(); 86 | return $pairs[$action] ?? []; 87 | } 88 | 89 | /** 90 | * @inheritdoc 91 | */ 92 | protected function defineRules(): array 93 | { 94 | $rules = parent::defineRules(); 95 | $rules[] = [['name'], 'required']; 96 | $rules[] = [ 97 | ['name'], 98 | UniqueValidator::class, 99 | 'targetClass' => SchemaRecord::class, 100 | ]; 101 | 102 | return $rules; 103 | } 104 | 105 | /** 106 | * Returns the schema’s config. 107 | * 108 | * @return array 109 | */ 110 | public function getConfig(): array 111 | { 112 | $config = [ 113 | 'name' => $this->name, 114 | ]; 115 | 116 | if ($this->scope) { 117 | $config['scope'] = $this->scope; 118 | } 119 | 120 | return $config; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/models/QueryApiToken.php: -------------------------------------------------------------------------------- 1 | _schema = $config['schema']; 72 | 73 | // We don't want any confusion here, so unset the schema ID, if they set a custom scope. 74 | unset($config['schemaId']); 75 | } 76 | 77 | unset($config['schema']); 78 | parent::__construct($config); 79 | } 80 | 81 | /** 82 | * @inheritdoc 83 | */ 84 | protected function defineRules(): array 85 | { 86 | $rules = parent::defineRules(); 87 | $rules[] = [['name', 'accessToken'], 'required']; 88 | $rules[] = [ 89 | ['name', 'accessToken'], 90 | UniqueValidator::class, 91 | 'targetClass' => TokenRecord::class, 92 | ]; 93 | 94 | return $rules; 95 | } 96 | 97 | /** 98 | * Use the translated group name as the string representation. 99 | * 100 | * @return string 101 | */ 102 | public function __toString(): string 103 | { 104 | return $this->name; 105 | } 106 | 107 | /** 108 | * Returns whether the token is enabled and has a schema assigned to it. 109 | * 110 | * @return bool 111 | */ 112 | public function getIsValid(): bool 113 | { 114 | return $this->enabled && $this->getSchema() !== null; 115 | } 116 | 117 | /** 118 | * Return the schema for this token. 119 | * 120 | * @return QueryApiSchema|null 121 | */ 122 | public function getSchema(): ?QueryApiSchema 123 | { 124 | if (empty($this->_schema) && !empty($this->schemaId)) { 125 | $this->_schema = QueryApi::getInstance()->schema->getSchemaById($this->schemaId); 126 | } 127 | 128 | return $this->_schema; 129 | } 130 | 131 | /** 132 | * Sets the schema for this token. 133 | * 134 | * @param QueryApiSchema $schema 135 | */ 136 | public function setSchema(QueryApiSchema $schema): void 137 | { 138 | $this->_schema = $schema; 139 | $this->schemaId = $schema->id; 140 | } 141 | 142 | /** 143 | * Return the schema's scope for this token. 144 | * 145 | * @return array|null 146 | */ 147 | public function getScope(): ?array 148 | { 149 | if (!isset($this->_scope)) { 150 | $schema = $this->getSchema(); 151 | $this->_scope = $schema->scope ?? null; 152 | } 153 | 154 | return $this->_scope; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/models/RegisterElementType.php: -------------------------------------------------------------------------------- 1 | hasOne(QueryApiSchema::class, ['id' => 'schemaId']); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/resources/QueryApiAsset.php: -------------------------------------------------------------------------------- 1 | !cb.classList.contains('select-all')); 5 | 6 | function toggleSelectAll(e) { 7 | if (e.target.checked) { 8 | checkboxesToToggle 9 | .filter(cb => !cb.checked) 10 | .forEach(cb => cb.click()); 11 | selectAllBtn.checked = true; 12 | } else { 13 | checkboxesToToggle 14 | .filter(cb => cb.checked) 15 | .forEach(cb => cb.click()); 16 | selectAllBtn.checked = false; 17 | } 18 | } 19 | 20 | function toggleCheckbox(e) { 21 | const checkbox = e.currentTarget; 22 | 23 | // uncheck select all btn if target checkboxes are unchecked 24 | if (selectAllBtn.checked === true && checkbox.checked === false) { 25 | selectAllBtn.checked = false; 26 | } 27 | if (checkbox.disabled) { 28 | e.preventDefault(); 29 | } 30 | } 31 | 32 | if (selectAllBtn) { 33 | selectAllBtn.addEventListener('click',(e) => toggleSelectAll(e)); 34 | 35 | if (selectAllBtn.checked) { 36 | checkboxesToToggle 37 | .filter(cb => !cb.checked) 38 | .forEach(cb => cb.click()); 39 | } 40 | } 41 | checkboxesToToggle.forEach(checkbox => { 42 | checkbox.addEventListener('click', toggleCheckbox); 43 | }); 44 | } 45 | 46 | document.querySelectorAll('.user-permissions').forEach(wrapper => { 47 | initUserPermissions(wrapper); 48 | }); 49 | 50 | -------------------------------------------------------------------------------- /src/resources/src/style.css: -------------------------------------------------------------------------------- 1 | h2.choose-permission-h2 { 2 | margin-bottom: 0; 3 | } 4 | 5 | .user-permissions { 6 | h3:first-child:not(.mt-0) { 7 | margin-block-start: 20px !important 8 | } 9 | 10 | .select-all + label{ 11 | font-weight: bold; 12 | } 13 | 14 | div.checkbox.disabled:before, 15 | input.checkbox:disabled + label, 16 | input.checkbox:disabled + label:before { 17 | opacity: .5 18 | } 19 | 20 | ul ul li:before { 21 | border-block-start: 1px solid #eee; 22 | content: ""; 23 | display: block; 24 | font-size: 0; 25 | height: 0; 26 | inset-inline-start: 16px; 27 | margin-block-start: 8px; 28 | position: absolute; 29 | width: 10px 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/services/CacheService.php: -------------------------------------------------------------------------------- 1 | getCache(); 16 | TagDependency::invalidate($cache, Constants::CACHE_TAG_GlOBAL); 17 | Craft::info( 18 | 'All query API caches cleared', 19 | __METHOD__ 20 | ); 21 | } 22 | 23 | public function getCacheDuration(): int 24 | { 25 | return QueryApi::getInstance()->getSettings()->cacheDuration 26 | ?? Craft::$app->getConfig()->getGeneral()->cacheDuration; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/services/ElementQueryService.php: -------------------------------------------------------------------------------- 1 | Address::class, 33 | 'assets' => Asset::class, 34 | 'entries' => Entry::class, 35 | 'users' => User::class, 36 | ]; 37 | 38 | private array $allowedMethods = [ 39 | 'addresses' => ['limit', 'id', 'status', 'offset', 'orderBy', 'search', 'addressLine1', 'addressLine2', 'addressLine3', 'locality', 'organization', 'fullName'], 40 | 'assets' => ['limit', 'id', 'status', 'offset', 'orderBy', 'search', 'volume', 'kind', 'filename', 'site', 'siteId'], 41 | 'entries' => ['limit', 'id', 'status', 'offset', 'orderBy', 'search', 'slug', 'uri', 'section', 'sectionId', 'postDate', 'site', 'siteId', 'level', 'type'], 42 | 'users' => ['limit', 'id', 'status', 'offset', 'orderBy', 'search', 'admin', 'group', 'groupId', 'authorOf', 'email', 'fullName', 'hasPhoto'], 43 | ]; 44 | 45 | /** 46 | * @throws Exception 47 | */ 48 | public function __construct() 49 | { 50 | parent::__construct(); 51 | $this->registerCustomElementType(); 52 | } 53 | 54 | /** 55 | * Handles the query execution for all element types. 56 | * @throws BadRequestHttpException|ForbiddenHttpException 57 | */ 58 | public function executeQuery(string $elementType, array $params, QueryApiSchema $schema): array 59 | { 60 | // Throw 400 if elementType is not defined 61 | if (!isset($this->elementTypeMap[$elementType])) { 62 | throw new BadRequestHttpException("No matching element type found for type: " . $elementType); 63 | } 64 | 65 | $this->schema = $schema; 66 | // Throw 403 if schema does not allow elementType 67 | Permissions::canQueryElement($elementType, $this->schema); 68 | 69 | $query = $this->buildElementQuery($elementType, $params); 70 | $queryOne = isset($params['one']) && $params['one'] === '1'; 71 | $queriedDataArr = $queryOne ? [$query->one()] : $query->all(); 72 | 73 | // Don't perform permission checks if schema has access to all (*:read) elements or is an empty arr. 74 | if (!Permissions::canQueryAllElement($elementType, $this->schema)) { 75 | foreach ($queriedDataArr as $queriedData) { 76 | if ($queriedData) { 77 | $this->_validateDataPermission($queriedData, $elementType); 78 | } 79 | } 80 | } 81 | 82 | return $queriedDataArr; 83 | } 84 | 85 | /** 86 | * 87 | * @throws ForbiddenHttpException 88 | * @throws Exception 89 | */ 90 | private function buildElementQuery(string $elementType, array $params) 91 | { 92 | $allowedMethods = $this->getAllowedMethods($elementType); 93 | $query = $this->elementTypeMap[$elementType]::find(); 94 | 95 | // makes sure that only entries with a section can get queried 96 | if ($elementType === 'entries') { 97 | $query->section('*'); 98 | } 99 | 100 | foreach ($params as $key => $value) { 101 | if (in_array($key, $allowedMethods)) { 102 | $query->$key($value); 103 | } 104 | } 105 | 106 | $eagerloadingMap = $this->getEagerLoadingMap(); 107 | $query->with($eagerloadingMap); 108 | 109 | return $query; 110 | } 111 | 112 | /** 113 | * Returns the allowed methods for the given element type. 114 | * 115 | * @param string $elementType 116 | * @return array 117 | * @throws Exception 118 | */ 119 | private function getAllowedMethods(string $elementType): array 120 | { 121 | if (!isset($this->allowedMethods[$elementType])) { 122 | throw new Exception('Unknown element type: ' . $elementType); 123 | } 124 | 125 | return $this->allowedMethods[$elementType]; 126 | } 127 | 128 | public function getEagerLoadingMap(): array 129 | { 130 | $mapKey = []; 131 | 132 | foreach (Craft::$app->getFields()->getAllFields() as $field) { 133 | if ($keys = $this->_getEagerLoadingMapForField($field)) { 134 | $mapKey[] = $keys; 135 | } 136 | } 137 | 138 | return array_merge(...$mapKey); 139 | } 140 | 141 | public function getCustomTransformers(): array 142 | { 143 | return $this->customTransformer; 144 | } 145 | 146 | public function getCustomElementTypes(): array 147 | { 148 | return $this->customElementTypes; 149 | } 150 | 151 | private function _getEagerLoadingMapForField(FieldInterface $field, ?string $prefix = null, int $iteration = 0): array 152 | { 153 | $keys = []; 154 | 155 | if ($field instanceof Matrix) { 156 | if ($iteration > 5) { 157 | return []; 158 | } 159 | 160 | $iteration++; 161 | 162 | // Because Matrix fields can be infinitely nested, we need to short-circuit things to prevent infinite looping. 163 | $keys[] = $prefix . $field->handle; 164 | 165 | foreach ($field->getEntryTypes() as $entryType) { 166 | foreach ($entryType->getCustomFields() as $subField) { 167 | $nestedKeys = $this->_getEagerLoadingMapForField($subField, $prefix . $field->handle . '.' . $entryType->handle . ':', $iteration); 168 | 169 | if ($nestedKeys) { 170 | $keys = array_merge($keys, $nestedKeys); 171 | } 172 | } 173 | } 174 | } 175 | 176 | if ($field instanceof BaseRelationField) { 177 | $keys[] = $prefix . $field->handle; 178 | } 179 | 180 | return $keys; 181 | } 182 | 183 | /** 184 | * Register custom element types through event 185 | * 186 | * @throws Exception 187 | */ 188 | private function registerCustomElementType(): void 189 | { 190 | if ($this->hasEventHandlers(self::EVENT_REGISTER_ELEMENT_TYPES)) { 191 | $event = new RegisterElementTypesEvent(); 192 | $this->trigger(self::EVENT_REGISTER_ELEMENT_TYPES, $event); 193 | $customElementTypes = $event->elementTypes; 194 | 195 | $customElementTypeMap = []; 196 | $customElementTypeMethodsMap = []; 197 | $customElementTypeTransformersMap = []; 198 | 199 | foreach ($customElementTypes as $customType) { 200 | // Validate required properties and add them to maps 201 | $this->validateCustomElementType($customType); 202 | 203 | // Make custom element type globally available 204 | $this->customElementTypes[$customType->elementTypeHandle] = $customType; 205 | 206 | // Build custom query map 207 | $customElementTypeMap[$customType->elementTypeHandle] = $customType->elementTypeClass; 208 | 209 | // Build allowed methods map 210 | $customElementTypeMethodsMap[$customType->elementTypeHandle] = $customType->allowedMethods; 211 | 212 | // Add the transformer map 213 | $customElementTypeTransformersMap[$customType->elementTypeClass] = $customType->transformer; 214 | } 215 | 216 | // Merge custom configurations 217 | $this->elementTypeMap = $customElementTypeMap + $this->elementTypeMap; 218 | $this->allowedMethods = $customElementTypeMethodsMap + $this->allowedMethods; 219 | $this->customTransformer = $customElementTypeTransformersMap + $this->customTransformer; 220 | } 221 | } 222 | 223 | /** 224 | * Validate custom element type for proper registration 225 | * @throws Exception 226 | */ 227 | private function validateCustomElementType($customType): void 228 | { 229 | $requiredProperties = ['elementTypeClass', 'elementTypeHandle', 'allowedMethods', 'transformer']; 230 | foreach ($requiredProperties as $property) { 231 | if (!property_exists($customType, $property) || !$customType->$property) { 232 | throw new Exception("Missing $property property in custom element type: " . json_encode($customType)); 233 | } 234 | } 235 | 236 | // Validate class and transformer existence 237 | if (!class_exists($customType->elementTypeClass)) { 238 | throw new Exception("Class {$customType->elementTypeClass} is not defined."); 239 | } 240 | if (!class_exists($customType->transformer)) { 241 | throw new Exception("Transformer class {$customType->transformer} is not defined."); 242 | } 243 | } 244 | 245 | /** 246 | * @throws ForbiddenHttpException 247 | */ 248 | private function _validateDataPermission($data, $elementType): void 249 | { 250 | switch ($elementType) { 251 | case 'addresses': 252 | if (!$this->schema->has("addresses.*:read")) { 253 | throw new ForbiddenHttpException("Schema doesn't have access to addresses"); 254 | } 255 | break; 256 | 257 | case 'assets': 258 | $volume = $data->getVolume(); 259 | if (!$this->schema->has("volumes.{$volume->uid}:read")) { 260 | throw new ForbiddenHttpException("Schema doesn't have access to volume with handle: {$volume->handle}"); 261 | } 262 | break; 263 | 264 | case 'entries': 265 | $site = $data->getSite(); 266 | 267 | if ( 268 | $site && 269 | !$this->schema->has("sites.{$site->uid}:read") && 270 | !$this->schema->has("sites.*:read") 271 | ) { 272 | throw new ForbiddenHttpException("Schema doesn't have access to site with handle: {$site->handle}"); 273 | } 274 | 275 | $section = $data->getSection(); 276 | if ($section && !$this->schema->has("sections.{$section->uid}:read")) { 277 | throw new ForbiddenHttpException("Schema doesn't have access to section with handle: {$section->handle}"); 278 | } 279 | break; 280 | 281 | case 'users': 282 | $userGroups = $data->getGroups(); 283 | 284 | if ($data->admin) { 285 | $userGroups[] = (object)[ 286 | 'uid' => 'admin', 287 | ]; 288 | } 289 | $hasAccess = collect($userGroups)->contains(function($group) { 290 | return $this->schema->has("usergroups.{$group->uid}:read"); 291 | }); 292 | 293 | if (!$hasAccess) { 294 | $groupHandles = implode(', ', array_map(fn($g) => $g->handle ?? 'admin', $userGroups)); 295 | 296 | throw new ForbiddenHttpException("Schema doesn't have access to one of the user groups: {$groupHandles}"); 297 | } 298 | break; 299 | 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/services/JsonTransformerService.php: -------------------------------------------------------------------------------- 1 | elementQueryService = $elementQueryService; 30 | $this->transformers = $this->elementQueryService->getCustomTransformers(); 31 | } 32 | 33 | /** 34 | * Transforms an array of elements using the appropriate transformers. 35 | * 36 | * @param array $arrResult 37 | * @param array $predefinedFields 38 | * @return array 39 | * @throws InvalidFieldException 40 | * @throws InvalidConfigException 41 | * @throws Exception 42 | */ 43 | public function executeTransform(array $arrResult, array $predefinedFields = []): array 44 | { 45 | return array_map(function($element) use ($predefinedFields) { 46 | if (!$element) { 47 | return []; 48 | } 49 | $transformer = $this->getTransformerForElement($element); 50 | return $transformer->getTransformedData($predefinedFields); 51 | }, $arrResult); 52 | } 53 | 54 | /** 55 | * Determines the appropriate transformer for the given element. 56 | * 57 | * @param mixed $element 58 | * @return BaseTransformer 59 | * @throws Exception 60 | */ 61 | private function getTransformerForElement(mixed $element): BaseTransformer 62 | { 63 | 64 | // Register custom transformers for custom element types 65 | if (count($this->transformers) > 0) { 66 | $elementTypeHandle = get_class($element); 67 | if (isset($this->transformers[$elementTypeHandle])) { 68 | $transformerClass = $this->transformers[$elementTypeHandle]; 69 | return new $transformerClass($element); 70 | } 71 | } 72 | 73 | return match (true) { 74 | $element instanceof Entry => new EntryTransformer($element), 75 | $element instanceof Asset => new AssetTransformer($element), 76 | $element instanceof User => new UserTransformer($element), 77 | $element instanceof Address => new AddressTransformer($element), 78 | $element instanceof Category => new CategoryTransformer($element), 79 | $element instanceof Tag => new TagTransformer($element), 80 | default => throw new Exception('Unsupported element type'), 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/services/SchemaService.php: -------------------------------------------------------------------------------- 1 | id; 30 | 31 | if ($runValidation && !$schema->validate()) { 32 | Craft::info('Schema not saved due to validation error.', __METHOD__); 33 | return false; 34 | } 35 | 36 | if ($isNewSchema && empty($schema->uid)) { 37 | $schema->uid = StringHelper::UUID(); 38 | } elseif (empty($schema->uid)) { 39 | $schema->uid = Db::uidById(Constants::TABLE_SCHEMAS, $schema->id); 40 | } 41 | 42 | $configPath = Constants::PATH_SCHEMAS . '.' . $schema->uid; 43 | $configData = $schema->getConfig(); 44 | Craft::$app->getProjectConfig()->set($configPath, $configData, "Save Query API schema “{$schema->name}”"); 45 | 46 | if ($isNewSchema) { 47 | $schema->id = Db::idByUid(Constants::TABLE_SCHEMAS, $schema->uid); 48 | } 49 | 50 | return true; 51 | } 52 | 53 | /** 54 | * Handle schema change 55 | * 56 | * @param ConfigEvent $event 57 | * @throws \yii\db\Exception 58 | */ 59 | public function handleChangedSchema(ConfigEvent $event): void 60 | { 61 | // Get the UID that was matched in the config path 62 | $uid = $event->tokenMatches[0]; 63 | 64 | // Does this schema exist? 65 | $id = Db::idByUid(Constants::TABLE_SCHEMAS, $uid); 66 | $isNew = empty($id); 67 | 68 | if ($isNew) { 69 | Db::insert(Constants::TABLE_SCHEMAS, [ 70 | 'name' => $event->newValue['name'], 71 | 'scope' => $event->newValue['scope'] ?? [], 72 | 'dateCreated' => new Expression('NOW()'), 73 | 'dateUpdated' => new Expression('NOW()'), 74 | 'uid' => $uid, 75 | ]); 76 | } else { 77 | Db::update(Constants::TABLE_SCHEMAS, [ 78 | 'name' => $event->newValue['name'], 79 | 'scope' => $event->newValue['scope'] ?? [], 80 | 'dateUpdated' => new Expression('NOW()'), 81 | ], ['id' => $id]); 82 | } 83 | } 84 | 85 | /** 86 | * @throws Exception 87 | */ 88 | public function handleDeletedSchema(ConfigEvent $event): void 89 | { 90 | // Get the UID that was matched in the config path: 91 | $uid = $event->tokenMatches[0]; 92 | $schema = $this->getSchemaByUid($uid); 93 | 94 | // If that came back empty, we’re done—must have already been deleted! 95 | if (!$schema) { 96 | return; 97 | } 98 | 99 | Db::delete(Constants::TABLE_SCHEMAS, ['id' => $schema->id]); 100 | } 101 | 102 | /** 103 | * Deletes a Query API schema by its ID. 104 | * 105 | * @param int $id The schema's ID 106 | * @return bool Whether the schema was deleted. 107 | */ 108 | public function deleteSchemaById(int $id): bool 109 | { 110 | $schema = $this->getSchemaById($id); 111 | 112 | if (!$schema) { 113 | return false; 114 | } 115 | 116 | return $this->deleteSchema($schema); 117 | } 118 | 119 | /** 120 | * Deletes a Query API schema. 121 | * 122 | * @param QueryApiSchema $schema 123 | * @return bool 124 | */ 125 | public function deleteSchema(QueryApiSchema $schema): bool 126 | { 127 | Craft::$app->getProjectConfig()->remove(Constants::PATH_SCHEMAS . '.' . $schema->uid, "Delete the “{$schema->name}” Query API schema"); 128 | return true; 129 | } 130 | 131 | /** 132 | * Get a schema by its ID. 133 | * 134 | * @param int $id The schema's ID 135 | * @return QueryApiSchema|null 136 | */ 137 | public function getSchemaById(int $id): ?QueryApiSchema 138 | { 139 | $result = $this->_createSchemaQuery() 140 | ->where(['id' => $id]) 141 | ->one(); 142 | 143 | return $result ? new QueryApiSchema($result) : null; 144 | } 145 | 146 | /** 147 | * Get a schema by its UID. 148 | * 149 | * @param string $uid The schema's UID 150 | * @return QueryApiSchema|null 151 | */ 152 | public function getSchemaByUid(string $uid): ?QueryApiSchema 153 | { 154 | $result = $this->_createSchemaQuery() 155 | ->where(['uid' => $uid]) 156 | ->one(); 157 | 158 | return $result ? new QueryApiSchema($result) : null; 159 | } 160 | 161 | /** 162 | * Get a schema by its name. 163 | * 164 | * @param string $name The schema's name 165 | * @return QueryApiSchema|null 166 | */ 167 | public function getSchemaByName(string $name): ?QueryApiSchema 168 | { 169 | $result = $this->_createSchemaQuery() 170 | ->where(['name' => $name]) 171 | ->one(); 172 | 173 | return $result ? new QueryApiSchema($result) : null; 174 | } 175 | 176 | /** 177 | * Get all schemas. 178 | * 179 | * @return QueryApiSchema[] 180 | */ 181 | public function getSchemas(): array 182 | { 183 | $rows = $this->_createSchemaQuery() 184 | ->all(); 185 | 186 | $schemas = []; 187 | 188 | foreach ($rows as $row) { 189 | $schemas[] = new QueryApiSchema($row); 190 | } 191 | 192 | return $schemas; 193 | } 194 | 195 | public function getSchemaComponents(): array 196 | { 197 | $queries = []; 198 | $mutations = []; 199 | 200 | // Sites 201 | $label = Craft::t('app', 'Sites'); 202 | [$queries[$label], $mutations[$label]] = $this->_siteSchemaComponents(); 203 | 204 | // Sections 205 | $label = Craft::t('app', 'Sections'); 206 | [$queries[$label], $mutations[$label]] = $this->_sectionSchemaComponents(); 207 | 208 | // User Groups 209 | $label = Craft::t('app', 'User Groups'); 210 | [$queries[$label], $mutations[$label]] = $this->_userSchemaComponents(); 211 | 212 | // Volumes 213 | $label = Craft::t('app', 'Volumes'); 214 | [$queries[$label], $mutations[$label]] = $this->_volumeSchemaComponents(); 215 | 216 | // Addresses 217 | $label = Craft::t('app', 'Addresses'); 218 | [$queries[$label], $mutations[$label]] = $this->_sectionSchemaAddresses(); 219 | 220 | // Cusom Elements 221 | list($customQueries, $customMutations) = $this->_sectionSchemaOther(); 222 | if (!empty($customQueries)) { 223 | $label = Craft::t('app', 'Custom Element Types'); 224 | $queries[$label] = $customQueries; 225 | $mutations[$label] = $customMutations; 226 | } 227 | 228 | return [ 229 | 'queries' => $queries, 230 | 'mutations' => $mutations, 231 | ]; 232 | } 233 | 234 | /** 235 | * Return site schema components. 236 | * 237 | * @return array 238 | */ 239 | private function _siteSchemaComponents(): array 240 | { 241 | $sites = Craft::$app->getSites()->getAllSites(true); 242 | $queryComponents["sites.*:read"] = [ 243 | 'label' => Craft::t('app', 'All sites'), 244 | 'class' => 'select-all', 245 | ]; 246 | 247 | foreach ($sites as $site) { 248 | $queryComponents["sites.{$site->uid}:read"] = [ 249 | 'label' => Craft::t('app', 'Query for elements in the “{site}” site', [ 250 | 'site' => $site->name, 251 | ]), 252 | ]; 253 | } 254 | 255 | return [$queryComponents, []]; 256 | } 257 | 258 | private function _sectionSchemaComponents(): array 259 | { 260 | $sections = Craft::$app->entries->getAllSections(); 261 | $queryComponents["sections.*:read"] = [ 262 | 'label' => Craft::t('app', 'All sections'), 263 | 'class' => 'select-all', 264 | ]; 265 | 266 | foreach ($sections as $section) { 267 | $queryComponents["sections.{$section->uid}:read"] = [ 268 | 'label' => Craft::t('app', 'Query for elements in the “{section}” section', [ 269 | 'section' => $section->name, 270 | ]), 271 | ]; 272 | } 273 | 274 | return [$queryComponents, []]; 275 | } 276 | 277 | private function _userSchemaComponents(): array 278 | { 279 | $userGroups = Craft::$app->userGroups->getAllGroups(); 280 | $queryComponents["usergroups.*:read"] = [ 281 | 'label' => Craft::t('app', 'All user groups'), 282 | 'class' => 'select-all', 283 | ]; 284 | 285 | $queryComponents["usergroups.admin:read"] = [ 286 | 'label' => Craft::t('app', 'Query for “Admin” users'), 287 | ]; 288 | 289 | foreach ($userGroups as $userGroup) { 290 | $queryComponents["usergroups.{$userGroup->uid}:read"] = [ 291 | 'label' => Craft::t('app', 'Query for users in the “{usergroup}” user group', [ 292 | 'usergroup' => $userGroup->name, 293 | ]), 294 | ]; 295 | } 296 | 297 | return [$queryComponents, []]; 298 | } 299 | 300 | private function _volumeSchemaComponents(): array 301 | { 302 | $volumes = Craft::$app->volumes->getAllVolumes(); 303 | $queryComponents["volumes.*:read"] = [ 304 | 'label' => Craft::t('app', 'All volumes'), 305 | 'class' => 'select-all', 306 | ]; 307 | 308 | foreach ($volumes as $volume) { 309 | $queryComponents["volumes.{$volume->uid}:read"] = [ 310 | 'label' => Craft::t('app', 'Query for assets in the “{volume}” volume', [ 311 | 'volume' => $volume->name, 312 | ]), 313 | ]; 314 | } 315 | 316 | return [$queryComponents, []]; 317 | } 318 | 319 | private function _sectionSchemaAddresses(): array 320 | { 321 | $queryComponents["addresses.*:read"] = [ 322 | 'label' => Craft::t('app', 'All addresses'), 323 | 'class' => 'select-all', 324 | ]; 325 | 326 | return [$queryComponents, []]; 327 | } 328 | 329 | private function _sectionSchemaOther(): array 330 | { 331 | $customElementTypes = QueryApi::getInstance()->query->getCustomElementTypes(); 332 | 333 | $queryComponents = []; 334 | foreach ($customElementTypes as $customElementType) { 335 | $queryComponents[$customElementType->elementTypeHandle . ":read"] = [ 336 | 'label' => Craft::t('app', 'Query for all elements of type “{handle}”', [ 337 | 'handle' => $customElementType->elementTypeHandle, 338 | ]), 339 | ]; 340 | } 341 | return [$queryComponents, []]; 342 | } 343 | 344 | /** 345 | * Returns a DbCommand object prepped for retrieving schemas. 346 | * 347 | * @return DbQuery 348 | */ 349 | private function _createSchemaQuery(): DbQuery 350 | { 351 | return (new DbQuery()) 352 | ->select([ 353 | 'id', 354 | 'name', 355 | 'scope', 356 | 'uid', 357 | ]) 358 | ->from([Constants::TABLE_SCHEMAS]); 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /src/services/TokenService.php: -------------------------------------------------------------------------------- 1 | id; 30 | 31 | if ($runValidation && !$token->validate()) { 32 | Craft::info('Token not saved due to validation error.', __METHOD__); 33 | return false; 34 | } 35 | 36 | if ($isNewToken) { 37 | $tokenRecord = new TokenRecord(); 38 | } else { 39 | $tokenRecord = TokenRecord::findOne($token->id) ?: new TokenRecord(); 40 | } 41 | 42 | $tokenRecord->name = $token->name; 43 | $tokenRecord->enabled = $token->enabled; 44 | $tokenRecord->schemaId = $token->schemaId; 45 | 46 | if ($token->accessToken) { 47 | $tokenRecord->accessToken = $token->accessToken; 48 | } 49 | 50 | $tokenRecord->save(); 51 | $token->id = $tokenRecord->id; 52 | $token->uid = $tokenRecord->uid; 53 | 54 | return true; 55 | } 56 | 57 | /** 58 | * Deletes a Query API token by its ID. 59 | * 60 | * @param int $id The token ID 61 | * @return bool Whether the token is deleted. 62 | */ 63 | public function deleteTokenById(int $id): bool 64 | { 65 | $record = TokenRecord::findOne($id); 66 | 67 | if (!$record) { 68 | return true; 69 | } 70 | 71 | return $record->delete(); 72 | } 73 | 74 | /** 75 | * Returns a Query API token by its ID. 76 | * 77 | * @param int $id 78 | * @return QueryApiToken|null 79 | */ 80 | public function getTokenById(int $id): ?QueryApiToken 81 | { 82 | $result = $this->_createTokenQuery() 83 | ->where(['id' => $id]) 84 | ->one(); 85 | 86 | return $result ? new QueryApiToken($result) : null; 87 | } 88 | 89 | /** 90 | * Returns a Query API token by its name. 91 | * 92 | * @param string $tokenName 93 | * @return QueryApiToken|null 94 | */ 95 | public function getTokenByName(string $tokenName): ?QueryApiToken 96 | { 97 | $result = $this->_createTokenQuery() 98 | ->where(['name' => $tokenName]) 99 | ->one(); 100 | 101 | return $result ? new QueryApiToken($result) : null; 102 | } 103 | 104 | /** 105 | * Returns a Query API token by its UID. 106 | * 107 | * @param string $uid 108 | * @return QueryApiToken 109 | * @throws InvalidArgumentException if $uid is invalid 110 | */ 111 | public function getTokenByUid(string $uid): QueryApiToken 112 | { 113 | $result = $this->_createTokenQuery() 114 | ->where(['uid' => $uid]) 115 | ->one(); 116 | 117 | if (!$result) { 118 | throw new InvalidArgumentException('Invalid UID'); 119 | } 120 | 121 | return new QueryApiToken($result); 122 | } 123 | 124 | /** 125 | * Returns a Query API token by its access token. 126 | * 127 | * @param string $token 128 | * @return QueryApiToken 129 | * @throws UnauthorizedHttpException 130 | */ 131 | public function getTokenByAccessToken(string $token): QueryApiToken 132 | { 133 | $result = $this->_createTokenQuery() 134 | ->where(['accessToken' => $token]) 135 | ->one(); 136 | 137 | if (!$result) { 138 | throw new UnauthorizedHttpException('Invalid access token, please provide valid token.'); 139 | } 140 | 141 | return new QueryApiToken($result); 142 | } 143 | 144 | public function getTokens(): array 145 | { 146 | $rows = $this->_createTokenQuery() 147 | ->all(); 148 | 149 | $tokens = []; 150 | 151 | foreach ($rows as $row) { 152 | $tokens[] = new QueryApiToken($row); 153 | } 154 | 155 | return $tokens; 156 | } 157 | 158 | public function getSchemaUsageInTokens(int $schemaId): array 159 | { 160 | $rows = $this->_createTokenQuery() 161 | ->where(['schemaId' => $schemaId]) 162 | ->all(); 163 | 164 | $usage = []; 165 | foreach ($rows as $row) { 166 | $token = new QueryApiToken($row); 167 | $usage[] = [ 168 | 'name' => $token->name, 169 | 'url' => UrlHelper::url('query-api/tokens/' . $token->id), 170 | ]; 171 | } 172 | 173 | return $usage; 174 | } 175 | 176 | public function getSchemaUsageInTokensAmount(int $schemaId): string 177 | { 178 | $amount = $this->_createTokenQuery() 179 | ->where(['schemaId' => $schemaId]) 180 | ->count(); 181 | 182 | return $amount . " Tokens"; 183 | } 184 | 185 | /** 186 | * @throws \yii\base\Exception 187 | */ 188 | public function generateToken(): string 189 | { 190 | return Craft::$app->getSecurity()->generateRandomString(32); 191 | } 192 | 193 | /** 194 | * Returns a DbCommand object prepped for retrieving tokens. 195 | * 196 | * @return DbQuery 197 | */ 198 | private function _createTokenQuery(): DbQuery 199 | { 200 | return (new DbQuery()) 201 | ->select([ 202 | 'id', 203 | 'schemaId', 204 | 'name', 205 | 'accessToken', 206 | 'enabled', 207 | 'dateCreated', 208 | 'dateUpdated', 209 | 'uid', 210 | ]) 211 | ->from([Constants::TABLE_TOKENS]); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/services/TypescriptService.php: -------------------------------------------------------------------------------- 1 | registerCustomTypeDefinitions(); 60 | } 61 | 62 | 63 | public function getTypes(): string 64 | { 65 | $schema = [ 66 | 'hardTypes' => $this->getHardTypes(), 67 | 'addresses' => $this->getAddressType(), 68 | 'assets' => $this->getAssetType(), 69 | 'entryTypes' => $this->getEntryTypesType(), 70 | 'users' => $this->getUserType(), 71 | 'categories' => $this->getCategoriesTypes(), 72 | 'tables' => $this->getTableTypes(), 73 | 'options' => $this->getOptionTypes(), 74 | ]; 75 | 76 | if (count($this->customTypeDefinitions) > 0) { 77 | $schema['customHardTypes'] = $this->getCustomHardTypes(); 78 | } 79 | 80 | $ts = $this->getAsciiBanner(); 81 | 82 | foreach ($schema as $key => $types) { 83 | $ts .= "// --- $key ---\n"; 84 | $ts .= $types . "\n\n"; 85 | } 86 | 87 | return $ts; 88 | } 89 | 90 | protected function registerCustomTypeDefinitions(): void 91 | { 92 | if ($this->hasEventHandlers(self::EVENT_REGISTER_TYPE_DEFINITIONS)) { 93 | $event = new RegisterTypeDefinitionEvent(); 94 | $this->trigger(self::EVENT_REGISTER_TYPE_DEFINITIONS, $event); 95 | $this->customTypeDefinitions = $event->typeDefinitions; 96 | } 97 | } 98 | 99 | protected function getCustomHardTypes(): string 100 | { 101 | $ts = ''; 102 | 103 | foreach ($this->customTypeDefinitions as $definition) { 104 | if (!$definition['staticHardType'] && !$definition['dynamicHardType']) { 105 | continue; 106 | } 107 | 108 | // dynamicHardType refers to a class that returns the type by calling setCustomHardTypes() 109 | if (!empty($definition['dynamicHardType'])) { 110 | $dynamicClass = $definition['dynamicHardType']; 111 | 112 | if (!class_exists($dynamicClass)) { 113 | Craft::error("Class {$dynamicClass} not found, dynamic hard type not applied.", 'queryApi'); 114 | break; 115 | } 116 | 117 | $transformer = new $dynamicClass(); 118 | 119 | if (!method_exists($transformer, 'setCustomHardTypes')) { 120 | Craft::error("Class {$dynamicClass} missing 'setCustomHardTypes' method, dynamic hard type not applied.", 'queryApi'); 121 | break; 122 | } 123 | 124 | $ts .= $transformer->setCustomHardTypes(); 125 | continue; 126 | } 127 | 128 | // staticHardType defines a fixed TypeScript type to assign without further logic 129 | if (!empty($definition['staticHardType'])) { 130 | $ts .= $definition['staticHardType']; 131 | } 132 | } 133 | 134 | return $ts; 135 | } 136 | 137 | protected function getTypesByFieldLayout(FieldLayout $fieldLayout): array 138 | { 139 | $fieldElements = array_merge($fieldLayout->getElementsByType(BaseField::class), $fieldLayout->getElementsByType(CustomField::class)); 140 | $tsFieldTypes = []; 141 | foreach ($fieldElements as $field) { 142 | $fieldClass = get_class($field); 143 | // only custom fields have the getField() method 144 | if (method_exists($field, 'getField')) { 145 | $field = $field->getField(); 146 | $fieldClass = get_class($field); 147 | } 148 | 149 | // skip all excluded field classes 150 | if (in_array($fieldClass, $this->getExcludedFieldClasses(), true)) { 151 | continue; 152 | } 153 | // skip addressField field as it gets managed by $this->getAddressType() 154 | if ($fieldClass === AddressField::class) { 155 | continue; 156 | } 157 | 158 | $fieldHandle = $field->handle ?? $field->attribute ?? 'unknown'; 159 | $isSingleRelation = Utils::isSingleRelationField($field); 160 | 161 | // Check for custom type definition 162 | foreach ($this->customTypeDefinitions as $definition) { 163 | if ($definition['fieldTypeClass'] !== $fieldClass) { 164 | continue; 165 | } 166 | 167 | // dynamicDefinitionClass refers to a class that returns the type by calling setTypeByField(field) 168 | if (!empty($definition['dynamicDefinitionClass'])) { 169 | $dynamicClass = $definition['dynamicDefinitionClass']; 170 | 171 | if (!class_exists($dynamicClass)) { 172 | Craft::error("Class {$dynamicClass} not found, dynamic type definition not applied.", 'queryApi'); 173 | break; 174 | } 175 | 176 | $transformer = new $dynamicClass($field); 177 | 178 | if (!method_exists($transformer, 'setTypeByField')) { 179 | Craft::error("Dynamic type definition {$dynamicClass} missing 'setTypeByField' method.", 'queryApi'); 180 | break; 181 | } 182 | 183 | $tsFieldTypes[$fieldHandle] = $transformer->setTypeByField($field); 184 | // break out to pass matcher and prevent overwrites 185 | continue 2; 186 | } 187 | 188 | // staticTypeDefinition defines a fixed TypeScript type to assign without further logic 189 | if (!empty($definition['staticTypeDefinition'])) { 190 | $tsFieldTypes[$fieldHandle] = $definition['staticTypeDefinition']; 191 | // break out to pass matcher and prevent overwrites 192 | continue 2; 193 | } 194 | } 195 | 196 | if (property_exists($field, 'type') && $field->type === 'text') { 197 | $tsType = $this->modifyTypeByField($field, 'string'); 198 | } else { 199 | $tsType = match ($fieldClass) { 200 | PlainText::class, Icon::class, Email::class, CountryCodeField::class, AltField::class, 'craft\ckeditor\Field' => $this->modifyTypeByField($field, 'string'), // @phpstan-ignore-line 201 | Range::class, Number::class => $this->modifyTypeByField($field, 'number'), 202 | Lightswitch::class => $this->modifyTypeByField($field, 'boolean'), 203 | Date::class, Time::class => $this->modifyTypeByField($field, 'CraftDateTime'), 204 | Color::class => $this->modifyTypeByField($field, 'CraftColor'), 205 | Country::class => $this->modifyTypeByField($field, 'CraftCountry'), 206 | Money::class => $this->modifyTypeByField($field, 'CraftMoney'), 207 | Link::class => $this->modifyTypeByField($field, 'CraftLink'), 208 | Json::class => $this->modifyTypeByField($field, 'CraftJson'), 209 | Tags::class => $this->modifyTypeByField($field, 'CraftTag'), 210 | Assets::class, PhotoField::class => $this->modifyTypeByField($field, 'CraftAsset'), 211 | Users::class => $this->modifyTypeByField($field, 'CraftUser'), 212 | Addresses::class => $this->modifyTypeByField($field, 'CraftAddress'), 213 | Entries::class => $this->modifyTypeByField($field, 'CraftEntryRelation'), 214 | Dropdown::class, RadioButtons::class, Checkboxes::class, MultiSelect::class, ButtonGroup::class => $this->modifyTypeByField($field, $this->getOptionTypeByField($field)), 215 | Matrix::class => $this->modifyTypeByField($field, $this->getEntryTypesByField($field)), 216 | Table::class => $this->modifyTypeByField($field, $this->getTableTypeByField($field)), 217 | Categories::class => $this->modifyTypeByField($field, $this->getCategoryTypeByField($field)), 218 | default => 'any', 219 | }; 220 | } 221 | 222 | $tsFieldTypes[$fieldHandle] = $tsType; 223 | } 224 | 225 | return $tsFieldTypes; 226 | } 227 | 228 | protected function modifyTypeByField($field, string $rawType): string 229 | { 230 | // rawType is for example CraftDateTime 231 | $type = $rawType; 232 | 233 | // type should be (CraftDateTime)[] 234 | $isSingleRelation = Utils::isArrayField($field); 235 | if ($isSingleRelation) { 236 | $type = '(' . $type . ')[]'; 237 | } 238 | 239 | // type should be CraftDateTime | null 240 | $isRequiredField = Utils::isRequiredField($field); 241 | if (!$isRequiredField) { 242 | $type .= ' | null'; 243 | } 244 | 245 | return $type; 246 | } 247 | 248 | protected function getAssetType(): string 249 | { 250 | $assetType = ''; 251 | $baseAssetType = [ 252 | 'metadata' => 'CraftAssetMeta', 253 | 'height' => 'number', 254 | 'width' => 'number', 255 | 'focalPoint' => 'CraftAssetFocalPoint', 256 | 'url' => 'string', 257 | 'title' => 'string', 258 | ]; 259 | 260 | $imageMode = AssetHelper::getAssetMode(); 261 | if ($imageMode === AssetMode::IMAGERX) { 262 | $srcSetKeys = AssetHelper::getImagerXTransformKeys(); 263 | $srcSetArr = []; 264 | foreach ($srcSetKeys as $srcSetKey) { 265 | $srcSetArr[$srcSetKey] = 'string'; 266 | } 267 | 268 | $assetType .= Typescript::buildTsType($srcSetArr, 'CraftAssetRatio') . "\n\n"; 269 | $baseAssetType['srcSets'] = 'CraftAssetRatio'; 270 | } 271 | 272 | $volumes = Craft::$app->volumes->getAllVolumes(); 273 | $allAssetTypes = []; 274 | foreach ($volumes as $volume) { 275 | $volumeName = StringHelper::toPascalCase($volume->handle); 276 | $fieldTypes = $this->getTypesByFieldLayout($volume->getFieldLayout()); 277 | $typeName = 'CraftVolume' . $volumeName; 278 | $allAssetTypes[] = $typeName; 279 | $assetType .= Typescript::buildTsType(array_merge($baseAssetType, $fieldTypes), $typeName) . "\n\n"; 280 | } 281 | 282 | $assetType .= 'export type CraftAsset = ' . implode(' | ', $allAssetTypes); 283 | 284 | return $assetType; 285 | } 286 | 287 | protected function getAddressType(): string 288 | { 289 | $baseAddressType = [ 290 | 'metadata' => 'CraftAddressMeta', 291 | 'title' => 'string', 292 | 'addressLine1' => 'string', 293 | 'addressLine2' => 'string', 294 | 'addressLine3' => 'string', 295 | 'countryCode' => 'string', 296 | 'locality' => 'string', 297 | 'postalCode' => 'string', 298 | ]; 299 | $fieldLayout = Craft::$app->addresses->getFieldLayout(); 300 | $fieldTypes = $this->getTypesByFieldLayout($fieldLayout); 301 | return Typescript::buildTsType(array_merge($baseAddressType, $fieldTypes), 'CraftAddress'); 302 | } 303 | 304 | protected function getUserType(): string 305 | { 306 | $baseAddressType = [ 307 | 'metadata' => 'CraftUserMeta', 308 | ]; 309 | 310 | $userFieldLayout = Craft::$app->getFields()->getLayoutByType(User::class); 311 | $fieldTypes = $this->getTypesByFieldLayout($userFieldLayout); 312 | return Typescript::buildTsType(array_merge($baseAddressType, $fieldTypes), 'CraftUser'); 313 | } 314 | 315 | protected function getEntryTypesType(): string 316 | { 317 | $ts = ''; 318 | // If entry type used in matrix or elsewhere = CraftEntryType + Name 319 | // If entry type used in section = CraftEntryPage + Name 320 | $allEntryTypes = Craft::$app->entries->getAllEntryTypes(); 321 | foreach ($allEntryTypes as $entryType) { 322 | $typeName = 'CraftEntryType' . StringHelper::toPascalCase($entryType->handle); 323 | $fieldTypes = $this->getTypesByFieldLayout($entryType->getFieldLayout()); 324 | $ts .= Typescript::buildTsType($fieldTypes, $typeName, 'interface') . "\n\n"; 325 | } 326 | 327 | $generatedEntryPageTypes = []; 328 | $allSections = Craft::$app->entries->getAllSections(); 329 | 330 | foreach ($allSections as $section) { 331 | foreach ($section->getEntryTypes() as $entryType) { 332 | $handle = StringHelper::toPascalCase($entryType->handle); 333 | $typeName = 'CraftPage' . $handle; 334 | 335 | // Already generated 336 | if (in_array($typeName, $generatedEntryPageTypes, true)) { 337 | continue; 338 | } 339 | 340 | $refTypeName = 'CraftEntryType' . $handle; 341 | 342 | $ts .= "export interface {$typeName} extends {$refTypeName} {\n"; 343 | $ts .= " metadata: CraftEntryMeta\n"; 344 | $ts .= " title: string\n"; 345 | $ts .= " sectionHandle: string\n"; 346 | $ts .= "}\n\n"; 347 | 348 | $generatedEntryPageTypes[] = $typeName; 349 | } 350 | } 351 | 352 | return $ts; 353 | } 354 | 355 | protected function getTableTypes(): string 356 | { 357 | $ts = ''; 358 | $allTableFields = Craft::$app->fields->getFieldsByType(Table::class); 359 | foreach ($allTableFields as $field) { 360 | $typeName = 'CraftTable' . StringHelper::toPascalCase($field->handle); 361 | 362 | $columns = $field->columns ?? []; 363 | if (empty($columns)) { 364 | continue; 365 | } 366 | 367 | $fieldTypes = []; 368 | foreach ($columns as $colKey => $colData) { 369 | $fieldTypes[$colKey] = 'string'; 370 | 371 | if (!empty($colData['handle'])) { 372 | $fieldTypes[$colData['handle']] = 'string'; 373 | } 374 | } 375 | 376 | $ts .= Typescript::buildTsType($fieldTypes, $typeName, 'interface') . "\n\n"; 377 | } 378 | return $ts; 379 | } 380 | 381 | protected function getOptionTypes(): string 382 | { 383 | $ts = ''; 384 | $baseOptionTypes = [ 385 | 'label' => 'string', 386 | 'selected' => 'boolean', 387 | 'valid' => 'boolean', 388 | ]; 389 | 390 | $optionClasses = [Dropdown::class, RadioButtons::class, Checkboxes::class, MultiSelect::class, 'craft\fields\ButtonGroup']; 391 | $fields = []; 392 | foreach ($optionClasses as $optionClass) { 393 | $fields = array_merge($fields, Craft::$app->getFields()->getFieldsByType($optionClass)); 394 | } 395 | 396 | foreach ($fields as $field) { 397 | $handle = StringHelper::toPascalCase($field->handle); 398 | $OptionTypeName = 'CraftOptionValue' . $handle; 399 | $optionValues = array_map(fn($option) => "'" . $option['value'] . "'", $field['options']); 400 | $optionValueType = implode(' | ', $optionValues); 401 | $ts .= "export type {$OptionTypeName} = {$optionValueType}\n\n"; 402 | $baseOptionTypes['value'] = $OptionTypeName; 403 | 404 | $typeName = 'CraftOption' . $handle; 405 | $ts .= Typescript::buildTsType($baseOptionTypes, $typeName) . "\n\n"; 406 | } 407 | 408 | return $ts; 409 | } 410 | 411 | protected function getCategoriesTypes(): string 412 | { 413 | $ts = ''; 414 | $allCategoryGroups = Craft::$app->categories->getAllGroups(); 415 | foreach ($allCategoryGroups as $group) { 416 | $handle = StringHelper::toPascalCase($group->handle); 417 | $typeName = 'CraftCategory' . $handle; 418 | $fieldTypes = $this->getTypesByFieldLayout($group->getFieldLayout()); 419 | $ts .= Typescript::buildTsType($fieldTypes, $typeName, 'interface') . "\n\n"; 420 | } 421 | 422 | return $ts; 423 | } 424 | 425 | protected function getEntryTypesByField(Matrix $field): string 426 | { 427 | $allAvailableMatrixBlockTypes = $field->getEntryTypes(); 428 | $availableTypes = []; 429 | foreach ($allAvailableMatrixBlockTypes as $entryType) { 430 | $availableTypes[] = 'CraftEntryType' . StringHelper::toPascalCase($entryType->handle); 431 | } 432 | 433 | return implode(' | ', $availableTypes); 434 | } 435 | 436 | protected function getTableTypeByField(Table $field): string 437 | { 438 | return 'CraftTable' . StringHelper::toPascalCase($field->handle); 439 | } 440 | 441 | protected function getOptionTypeByField($field): string 442 | { 443 | return 'CraftOption' . StringHelper::toPascalCase($field->handle); 444 | } 445 | 446 | protected function getCategoryTypeByField(Categories $field): string 447 | { 448 | $categoryUid = explode(':', $field->source)[1]; 449 | $group = Craft::$app->categories->getGroupByUid($categoryUid); 450 | 451 | if (!$group) { 452 | return 'unknown'; 453 | } 454 | 455 | $handle = StringHelper::toPascalCase($group->handle); 456 | return 'CraftCategory' . $handle; 457 | } 458 | 459 | protected function getExcludedFieldClasses(): array 460 | { 461 | if (isset(QueryApi::getInstance()->getSettings()->excludeFieldClasses)) { 462 | return array_merge(Constants::EXCLUDED_FIELD_HANDLES, QueryApi::getInstance()->getSettings()->excludeFieldClasses); 463 | } 464 | 465 | return Constants::EXCLUDED_FIELD_HANDLES; 466 | } 467 | 468 | protected function getHardTypes(): string 469 | { 470 | return <<>> Comp Settings #} 2 | {% set compDefaults = { 3 | data: { 4 | name: 'c-checkboxList', 5 | schema: null, 6 | permissions: null, 7 | id: null, 8 | disabled: false, 9 | headline: '', 10 | }, 11 | classes: { 12 | root: '', 13 | custom: '', 14 | } 15 | } %} 16 | 17 | {# >>> Merge data / classes / variansts (optional) #} 18 | {% set props = { 19 | data: data is defined and data is iterable ? compDefaults.data | merge(data) : compDefaults.data, 20 | classes: classes is defined and classes is iterable ? compDefaults.classes | merge(classes) : compDefaults.classes, 21 | } %} 22 | 23 | {% from "_includes/forms" import checkbox %} 24 |
25 |

{{ props.data.headline }}

26 |
    27 | {% for permissionName, data in props.data.permissions %} 28 | {% if props.data.schema and props.data.schema.has(permissionName) %} 29 | {% set checked = true %} 30 | {% else %} 31 | {% set checked = false %} 32 | {% endif %} 33 |
  • 34 | {{ checkbox({ 35 | label: data.label, 36 | name: 'permissions[]', 37 | value: permissionName, 38 | checked: checked, 39 | disabled: props.data.disabled, 40 | class: data.class ?? '', 41 | }) }} 42 |
  • 43 | {% endfor %} 44 |
45 |
46 | -------------------------------------------------------------------------------- /src/templates/schemas/_edit.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp.twig" %} 2 | 3 | {% set selectedSubnavItem = 'schemas' %} 4 | {% set fullPageForm = true %} 5 | {% import "_includes/forms" as forms %} 6 | 7 | {% set crumbs = [ 8 | { label: "Query API Schemas", url: url('query-api/schemas') } 9 | ] %} 10 | 11 | {% set formActions = [ 12 | { 13 | label: 'Save and continue editing', 14 | redirect: 'query-api/schemas/{id}'|hash, 15 | shortcut: true, 16 | retainScroll: true, 17 | } 18 | ] %} 19 | 20 | {% set schemaComps = getSchemaComponents() %} 21 | 22 | {% do view.registerAssetBundle("samuelreichoer\\queryapi\\resources\\QueryApiAsset") %} 23 | {% block content %} 24 | {{ actionInput('query-api/schema/save-schema') }} 25 | {{ redirectInput('query-api/schemas') }} 26 | 27 | {% if schema.id %}{{ hiddenInput('schemaId', schema.id) }}{% endif %} 28 | 29 | {{ forms.textField({ 30 | first: true, 31 | label: "Name"|t('app'), 32 | instructions: "What this schema will be called in the control panel."|t('app'), 33 | id: 'name', 34 | name: 'name', 35 | value: schema.name, 36 | autofocus: true, 37 | errors: schema.getErrors('name'), 38 | required: true 39 | }) }} 40 | 41 |
42 | 43 |

Choose the available content for querying with this schema

44 | {% for catName, props in schemaComps.queries %} 45 | {{ include('query-api/_components/checkboxList/checkboxList.twig', { 46 | data: { 47 | headline: catName, 48 | permissions: props, 49 | schema: schema, 50 | } 51 | }, with_context = false) }} 52 | {% endfor %} 53 | {% endblock %} 54 | 55 | {% block details %} 56 | {% if usage %} 57 |
58 |
59 |
Used By
60 |
61 | 66 |
67 | 68 |
69 |
70 | {% endif %} 71 | {% endblock %} 72 | 73 | {% js %} 74 | new Craft.ElevatedSessionForm('#main-form'); 75 | {% endjs %} 76 | -------------------------------------------------------------------------------- /src/templates/schemas/_index.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp.twig" %} 2 | {% set title = "Query API Schemas" %} 3 | {% set selectedSubnavItem = 'schemas' %} 4 | 5 | {% do view.registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset') -%} 6 | 7 | {% block actionButton %} 8 | {{ "New schema"|t('app') }} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | {% endblock %} 14 | 15 | {% js %} 16 | const columns = [ 17 | { name: '__slot:title', title: Craft.t('app', 'Name') }, 18 | { name: 'usage', title: Craft.t('app', 'Used by') } 19 | ]; 20 | 21 | new Craft.VueAdminTable({ 22 | columns: columns, 23 | container: '#schemas-vue-admin-table', 24 | deleteAction: 'query-api/schema/delete-schema', 25 | emptyMessage: 'No Query API schemas exist yet.', 26 | tableData: {{ getAllSchemas()|json_encode|raw }} 27 | }); 28 | {% endjs %} 29 | -------------------------------------------------------------------------------- /src/templates/tokens/_edit.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp" %} 2 | 3 | {% set selectedSubnavItem = 'tokens' %} 4 | 5 | {% set fullPageForm = true %} 6 | 7 | {% set crumbs = [ 8 | { label: "Query API Tokens"|t('app'), url: url('query-api/tokens') } 9 | ] %} 10 | 11 | {% set formActions = [ 12 | { 13 | label: 'Save and continue editing', 14 | redirect: 'query-api/tokens/{id}'|hash, 15 | shortcut: true, 16 | retainScroll: true, 17 | } 18 | ] %} 19 | 20 | {% import "_includes/forms" as forms %} 21 | 22 | {% block content %} 23 | {{ actionInput('query-api/token/save-token') }} 24 | {{ redirectInput('query-api/tokens') }} 25 | {% if token.id %}{{ hiddenInput('tokenId', token.id) }}{% endif %} 26 | 27 | {{ forms.textField({ 28 | first: true, 29 | label: "Name"|t('app'), 30 | instructions: "What this token will be called in the control panel."|t('app'), 31 | id: 'name', 32 | name: 'name', 33 | value: token.name, 34 | errors: token.getErrors('name'), 35 | autofocus: true, 36 | required: true, 37 | }) }} 38 | 39 | {% set schemaInput = schemaOptions 40 | ? forms.selectField({ 41 | name: 'schema', 42 | id: 'schema', 43 | options: schemaOptions, 44 | value: token.schemaId, 45 | }) 46 | : tag('p', { 47 | class: ['warning', 'with-icon'], 48 | text: 'No schemas exist yet to assign to this token.'|t('app'), 49 | }) %} 50 | 51 | {{ forms.field({ 52 | id: 'schema', 53 | label: 'Query API Schema', 54 | instructions: 'Choose which Query API schema this token has access to.', 55 | }, schemaInput) }} 56 | 57 |
58 | 59 | {% embed '_includes/forms/field' with { 60 | label: 'Authorization Header'|t('app'), 61 | instructions: 'The `Authorization` header that should be sent with Query API requests to use this token.'|t('app'), 62 | id: 'auth-header', 63 | } %} 64 | {% block input %} 65 | {% import '_includes/forms' as forms %} 66 |
67 | {% embed '_includes/forms/copytext' with { 68 | id: 'auth-header', 69 | buttonId: 'copy-btn', 70 | value: 'Authorization: Bearer ' ~ (accessToken ?? '••••••••••••••••••••••••••••••••'), 71 | errors: token.getErrors('accessToken'), 72 | class: ['code', not accessToken ? 'disabled']|filter, 73 | size: 54, 74 | } %} 75 | {# don't register the default JS #} 76 | {% block js %}{% endblock %} 77 | {% endembed %} 78 | {{ hiddenInput('accessToken', accessToken, { 79 | id: 'access-token', 80 | disabled: not accessToken, 81 | }) }} 82 | 83 | 84 |
85 | {% endblock %} 86 | {% endembed %} 87 | {% endblock %} 88 | 89 | {% block details %} 90 |
91 | {{ forms.lightswitchField({ 92 | label: 'Enabled'|t('app'), 93 | id: 'enabled', 94 | name: 'enabled', 95 | on: token.enabled, 96 | }) }} 97 |
98 | {% endblock %} 99 | 100 | {% js %} 101 | var $headerInput = $('#auth-header'); 102 | var $tokenInput = $('#access-token'); 103 | var $regenBtn = $('#regen-btn'); 104 | var regenerating = false; 105 | 106 | function copyHeader() { 107 | $headerInput[0].select(); 108 | document.execCommand('copy'); 109 | Craft.cp.displayNotice("{{ 'Copied to clipboard.'|t('app')|e('js') }}"); 110 | } 111 | 112 | $headerInput.on('click', function () { 113 | if (!$headerInput.hasClass('disabled')) { 114 | this.select(); 115 | } 116 | }); 117 | 118 | $('#copy-btn').on('click', function () { 119 | if (!$headerInput.hasClass('disabled')) { 120 | copyHeader(); 121 | } else { 122 | Craft.elevatedSessionManager.requireElevatedSession(function () { 123 | $('#token-spinner').removeClass('hidden'); 124 | var data = {{ {tokenUid: token.uid}|json_encode|raw }}; 125 | Craft.sendActionRequest('POST', 'query-api/token/fetch-token', {data}) 126 | .then((response) => { 127 | console.log(response); 128 | $('#token-spinner').addClass('hidden'); 129 | $headerInput 130 | .val('Authorization: Bearer ' + response.data.accessToken) 131 | .removeClass('disabled'); 132 | copyHeader(); 133 | }) 134 | .finally(() => { 135 | $('#token-spinner').addClass('hidden'); 136 | }); 137 | }); 138 | } 139 | }); 140 | 141 | $regenBtn.on('click', function () { 142 | if (regenerating) { 143 | return; 144 | } 145 | regenerating = true; 146 | $('#token-spinner').removeClass('hidden'); 147 | $regenBtn.addClass('active'); 148 | 149 | Craft.sendActionRequest('POST', 'query-api/token/generate-token') 150 | .then((response) => { 151 | $headerInput 152 | .val('Authorization: Bearer ' + response.data.accessToken) 153 | .removeClass('disabled'); 154 | $tokenInput 155 | .val(response.data.accessToken) 156 | .prop('disabled', false); 157 | $regenBtn.removeClass('active'); 158 | regenerating = false; 159 | }) 160 | .finally(() => { 161 | $('#token-spinner').addClass('hidden'); 162 | }); 163 | }); 164 | 165 | new Craft.ElevatedSessionForm('#main-form'); 166 | {% endjs %} 167 | 168 | -------------------------------------------------------------------------------- /src/templates/tokens/_index.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp.twig" %} 2 | {% set title = "Query API Tokens" %} 3 | {% set selectedSubnavItem = 'tokens' %} 4 | 5 | {% do view.registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset') -%} 6 | 7 | {% block actionButton %} 8 | {{ "New token"|t('app') }} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |
13 | {% endblock %} 14 | 15 | {% js %} 16 | var columns = [ 17 | { name: '__slot:title', title: Craft.t('app', 'Name') }, 18 | { name: 'dateCreated', title: Craft.t('app', 'Date Created') }, 19 | { name: 'dateUpdated', title: Craft.t('app', 'Date Updated') }, 20 | ]; 21 | 22 | new Craft.VueAdminTable({ 23 | columns: columns, 24 | container: '#tokens-vue-admin-table', 25 | deleteAction: 'query-api/token/delete-token', 26 | emptyMessage: Craft.t('app', 'No Query API tokens exist yet.'), 27 | tableData: {{ getAllTokens()|json_encode|raw }} 28 | }); 29 | {% endjs %} 30 | -------------------------------------------------------------------------------- /src/transformers/AddressTransformer.php: -------------------------------------------------------------------------------- 1 | address = $address; 14 | } 15 | 16 | /** 17 | * @param array $predefinedFields 18 | * @return array 19 | */ 20 | public function getTransformedData(array $predefinedFields = []): array 21 | { 22 | $metaData = $this->getMetaData(); 23 | 24 | return [ 25 | 'metadata' => $metaData, 26 | 'title' => $this->address->title ?? '', 27 | 'addressLine1' => $this->address->addressLine1 ?? '', 28 | 'addressLine2' => $this->address->addressLine2 ?? '', 29 | 'addressLine3' => $this->address->addressLine3 ?? '', 30 | 'countryCode' => $this->address->countryCode ?? '', 31 | 'locality' => $this->address->locality ?? '', 32 | 'postalCode' => $this->address->postalCode ?? '', 33 | ]; 34 | } 35 | 36 | /** 37 | * Retrieves metadata from the Address. 38 | * 39 | * @return array 40 | */ 41 | protected function getMetaData(): array 42 | { 43 | return [ 44 | 'id' => $this->address->id, 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/transformers/AssetTransformer.php: -------------------------------------------------------------------------------- 1 | asset = $asset; 21 | } 22 | 23 | /** 24 | * @param array $predefinedFields 25 | * @return array 26 | * @throws ImageTransformException 27 | * @throws InvalidConfigException 28 | * @throws InvalidFieldException 29 | */ 30 | public function getTransformedData(array $predefinedFields = []): array 31 | { 32 | $imageMode = AssetHelper::getAssetMode(); 33 | 34 | if ($imageMode === AssetMode::IMAGERX) { 35 | return $this->imagerXTransformer(); 36 | } 37 | 38 | return $this->defaultImageTransformer(); 39 | } 40 | 41 | /** 42 | * @return array 43 | * @throws InvalidConfigException 44 | * @throws ImageTransformException 45 | * @throws InvalidFieldException 46 | */ 47 | private function defaultImageTransformer(): array 48 | { 49 | $transformedFields = $this->getTransformedFields(); 50 | return array_merge([ 51 | 'metadata' => $this->getMetaData(), 52 | 'height' => $this->asset->getHeight(), 53 | 'width' => $this->asset->getWidth(), 54 | 'focalPoint' => $this->asset->getFocalPoint(), 55 | 'url' => $this->asset->getUrl(), 56 | ], $transformedFields); 57 | } 58 | 59 | /** 60 | * @return array 61 | * @throws ImageTransformException 62 | * @throws InvalidConfigException 63 | * @throws InvalidFieldException 64 | */ 65 | private function imagerXTransformer(): array 66 | { 67 | $data = $this->defaultImageTransformer(); 68 | $data['srcSets'] = $this->getAllAvailableSrcSets(); 69 | 70 | return $data; 71 | } 72 | 73 | /** 74 | * @return array 75 | * @throws ImageTransformException 76 | * @throws InvalidConfigException 77 | */ 78 | protected function getMetaData(): array 79 | { 80 | return [ 81 | 'id' => $this->asset->getId(), 82 | 'filename' => $this->asset->getFilename(), 83 | 'kind' => $this->asset->kind, 84 | 'size' => $this->asset->getFormattedSize(), 85 | 'mimeType' => $this->asset->getMimeType(), 86 | 'extension' => $this->asset->getExtension(), 87 | 'cpEditUrl' => $this->asset->getCpEditUrl(), 88 | 'volumeId' => $this->asset->volume->getId(), 89 | ]; 90 | } 91 | 92 | /** 93 | * @return array 94 | */ 95 | private function getAllAvailableSrcSets(): array 96 | { 97 | $transforms = AssetHelper::getImagerXTransformKeys(); 98 | $imagerX = Craft::$app->plugins->getPlugin('imager-x'); 99 | $srcSetArr = []; 100 | 101 | foreach ($transforms as $transform) { 102 | $imagerClass = $imagerX->imager ?? null; 103 | if ($imagerClass) { 104 | $transformedImages = $imagerClass->transformImage($this->asset, $transform); 105 | $srcSetArr[$transform] = $imagerClass->srcset($transformedImages); 106 | } 107 | } 108 | 109 | return $srcSetArr; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/transformers/BaseTransformer.php: -------------------------------------------------------------------------------- 1 | element = $element; 40 | $this->registerCustomTransformers(); 41 | $this->excludeFieldClasses = $this->getExcludedFieldClasses(); 42 | } 43 | 44 | /** 45 | * Transforms the element into an array. 46 | * 47 | * @param array $predefinedFields 48 | * @return array 49 | * @throws InvalidConfigException 50 | * @throws InvalidFieldException 51 | * @throws ImageTransformException 52 | */ 53 | public function getTransformedData(array $predefinedFields = []): array 54 | { 55 | $transformedFields = $this->getTransformedFields($predefinedFields); 56 | $metadata = $this->getMetaData(); 57 | 58 | return array_merge($metadata, $transformedFields); 59 | } 60 | 61 | /** 62 | * Retrieves and transforms custom fields from the element. 63 | * 64 | * @param array $predefinedFields 65 | * @return array 66 | * @throws InvalidConfigException 67 | * @throws InvalidFieldException 68 | * @throws ImageTransformException 69 | */ 70 | protected function getTransformedFields(array $predefinedFields = []): array 71 | { 72 | $fieldLayout = $this->element->getFieldLayout(); 73 | $fieldElements = array_merge($fieldLayout->getElementsByType(BaseField::class), $fieldLayout->getElementsByType(CustomField::class)); 74 | $transformedFields = []; 75 | 76 | foreach ($fieldElements as $field) { 77 | $fieldClass = get_class($field); 78 | 79 | // only custom fields have the getField() method 80 | if (method_exists($field, 'getField')) { 81 | $field = $field->getField(); 82 | $fieldClass = get_class($field); 83 | } 84 | 85 | $fieldHandle = $field->handle ?? $field->attribute ?? ''; 86 | 87 | if ($fieldHandle === '') { 88 | continue; 89 | } 90 | 91 | // Check if fieldHandle is in predefinedFields or if predefinedFields is empty 92 | if (!empty($predefinedFields) && !in_array($fieldHandle, $predefinedFields, true)) { 93 | continue; 94 | } 95 | 96 | if (in_array($fieldClass, $this->excludeFieldClasses, true)) { 97 | continue; 98 | } 99 | 100 | $isSingleRelation = Utils::isSingleRelationField($field); 101 | try { 102 | $fieldValue = $this->element->getFieldValue($fieldHandle); 103 | $transformedFields[$fieldHandle] = $this->getTransformedCustomFieldData($isSingleRelation, $fieldValue, $fieldClass); 104 | } catch (InvalidFieldException) { 105 | // handle native fields 106 | $fieldValue = $this->element->$fieldHandle; 107 | $transformedFields[$fieldHandle] = $this->transformNativeField($fieldValue, $fieldClass); 108 | } 109 | } 110 | 111 | return $transformedFields; 112 | } 113 | 114 | /** 115 | * Transforms a custom field based on its class. 116 | * 117 | * @param mixed $fieldValue 118 | * @param string $fieldClass 119 | * @return mixed 120 | * @throws InvalidFieldException|InvalidConfigException 121 | * @throws ImageTransformException 122 | */ 123 | protected function transformCustomField(mixed $fieldValue, string $fieldClass): mixed 124 | { 125 | if (!$fieldValue || !$fieldClass) { 126 | return null; 127 | } 128 | 129 | // Check for custom transformers from EVENT_REGISTER_FIELD_TRANSFORMERS 130 | foreach ($this->customTransformers as $customTransformer) { 131 | if ($customTransformer['fieldClass'] === $fieldClass) { 132 | $transformerClass = $customTransformer['transformer']; 133 | 134 | if (!class_exists($transformerClass)) { 135 | Craft::error("Transformer class {$transformerClass} not found.", 'queryApi'); 136 | break; 137 | } 138 | 139 | $transformer = new $transformerClass($fieldValue); 140 | 141 | if (!method_exists($transformer, 'getTransformedData')) { 142 | Craft::error("Transformer {$transformerClass} does not have a 'getTransformedData' method.", 'queryApi'); 143 | return null; 144 | } 145 | 146 | return $transformer->getTransformedData(); 147 | } 148 | } 149 | 150 | return match ($fieldClass) { 151 | Addresses::class => $this->transformAddresses($fieldValue->all()), 152 | Assets::class => $this->transformAssets($fieldValue->all()), 153 | Categories::class => $this->transformCategories($fieldValue->all()), 154 | Color::class => $this->transformColor($fieldValue), 155 | Country::class => $this->transformCountry($fieldValue), 156 | Entries::class => $this->transformEntries($fieldValue->all()), 157 | Matrix::class => $this->transformMatrixField($fieldValue->all()), 158 | Link::class => $this->transformLinks($fieldValue), 159 | Tags::class => $this->transformTags($fieldValue->all()), 160 | Users::class => $this->transformUsers($fieldValue->all()), 161 | default => $fieldValue, 162 | }; 163 | } 164 | 165 | /** 166 | * Transforms a native field based on its class. 167 | * 168 | * @param mixed $fieldValue 169 | * @param string $fieldClass 170 | * @return mixed 171 | */ 172 | protected function transformNativeField(mixed $fieldValue, string $fieldClass): mixed 173 | { 174 | if (!$fieldValue || !$fieldClass) { 175 | return null; 176 | } 177 | 178 | switch ($fieldClass) { 179 | case 'craft\fieldlayoutelements\users\PhotoField': 180 | $assetTransformer = new AssetTransformer($fieldValue); 181 | return $assetTransformer->getTransformedData(); 182 | 183 | default: 184 | return $fieldValue; 185 | } 186 | } 187 | 188 | /** 189 | * Retrieves metadata from the element. 190 | * 191 | * @return array 192 | */ 193 | protected function getMetaData(): array 194 | { 195 | return [ 196 | 'id' => $this->element->getId(), 197 | ]; 198 | } 199 | 200 | /** 201 | * Transforms a Matrix field. 202 | * 203 | * @param array $matrixFields 204 | * @return array 205 | * @throws InvalidFieldException|InvalidConfigException|ImageTransformException 206 | */ 207 | protected function transformMatrixField(array $matrixFields): array 208 | { 209 | $transformedData = []; 210 | 211 | foreach ($matrixFields as $block) { 212 | $blockData = [ 213 | 'type' => $block->type->handle, 214 | ]; 215 | 216 | if ($block->title) { 217 | $blockData['title'] = $block->title; 218 | } 219 | 220 | foreach ($block->getFieldValues() as $fieldHandle => $fieldValue) { 221 | $field = $block->getFieldLayout()->getFieldByHandle($fieldHandle); 222 | 223 | // Check if field has a limit of relations 224 | $isSingleRelation = Utils::isSingleRelationField($field); 225 | $fieldClass = get_class($field); 226 | 227 | // Exclude fields in matrix blocks 228 | if (in_array($fieldClass, $this->excludeFieldClasses, true)) { 229 | continue; 230 | } 231 | 232 | $blockData[$fieldHandle] = $this->getTransformedCustomFieldData($isSingleRelation, $fieldValue, $fieldClass); 233 | } 234 | 235 | $transformedData[] = $blockData; 236 | } 237 | 238 | return $transformedData; 239 | } 240 | 241 | /** 242 | * Transforms an array of Asset elements. 243 | * 244 | * @throws ImageTransformException 245 | * @throws InvalidConfigException 246 | * @throws InvalidFieldException 247 | */ 248 | protected function transformAssets(array $assets): array 249 | { 250 | $transformedData = []; 251 | foreach ($assets as $asset) { 252 | $assetTransformer = new AssetTransformer($asset); 253 | $transformedData[] = $assetTransformer->getTransformedData(); 254 | } 255 | return $transformedData; 256 | } 257 | 258 | /** 259 | * Transforms an array of Entry elements. 260 | * 261 | * @param array $entries 262 | * @return array 263 | */ 264 | protected function transformEntries(array $entries): array 265 | { 266 | $transformedData = []; 267 | foreach ($entries as $entry) { 268 | $transformedData[] = [ 269 | 'title' => $entry->title, 270 | 'slug' => $entry->slug, 271 | 'url' => $entry->url, 272 | 'id' => $entry->id, 273 | ]; 274 | } 275 | return $transformedData; 276 | } 277 | 278 | /** 279 | * Transforms an array of User elements. 280 | * 281 | * @param array $users 282 | * @return array 283 | */ 284 | protected function transformUsers(array $users): array 285 | { 286 | $transformedData = []; 287 | foreach ($users as $user) { 288 | $userTransformer = new UserTransformer($user); 289 | $transformedData[] = $userTransformer->getTransformedData(); 290 | } 291 | return $transformedData; 292 | } 293 | 294 | /** 295 | * Transforms an array of Category elements. 296 | * 297 | * @param array $categories 298 | * @return array 299 | */ 300 | protected function transformCategories(array $categories): array 301 | { 302 | $transformedData = []; 303 | foreach ($categories as $category) { 304 | $categoryTransformer = new CategoryTransformer($category); 305 | $transformedData[] = $categoryTransformer->getTransformedData(); 306 | } 307 | return $transformedData; 308 | } 309 | 310 | /** 311 | * Transforms an array of Tag elements. 312 | * 313 | * @param array $tags 314 | * @return array 315 | */ 316 | protected function transformTags(array $tags): array 317 | { 318 | $transformedData = []; 319 | foreach ($tags as $tag) { 320 | $tagTransformer = new TagTransformer($tag); 321 | $transformedData[] = $tagTransformer->getTransformedData(); 322 | } 323 | return $transformedData; 324 | } 325 | 326 | /** 327 | * Transforms a Link field. 328 | * 329 | * @param mixed $link 330 | * @return array 331 | */ 332 | protected function transformLinks(mixed $link): array 333 | { 334 | if (empty($link)) { 335 | return []; 336 | } 337 | 338 | return [ 339 | 'elementType' => $link->type, 340 | 'url' => $link->url, 341 | 'label' => $link->label, 342 | 'target' => $link->target ?? '_self', 343 | 'rel' => $link->rel, 344 | 'urlSuffix' => $link->urlSuffix, 345 | 'class' => $link->class, 346 | 'id' => $link->id, 347 | 'ariaLabel' => $link->ariaLabel, 348 | 'download' => $link->download, 349 | 'downloadFile' => $link->filename, 350 | ]; 351 | } 352 | 353 | /** 354 | * Transforms an array of User elements. 355 | * 356 | * @param array $addresses 357 | * @return array 358 | */ 359 | protected function transformAddresses(array $addresses): array 360 | { 361 | $transformedData = []; 362 | foreach ($addresses as $address) { 363 | $addressTransformer = new AddressTransformer($address); 364 | $transformedData[] = $addressTransformer->getTransformedData(); 365 | } 366 | return $transformedData; 367 | } 368 | 369 | /** 370 | * Transforms a color element 371 | * 372 | * @param mixed $color 373 | * @return array 374 | */ 375 | protected function transformColor(mixed $color): array 376 | { 377 | if (empty($color)) { 378 | return []; 379 | } 380 | 381 | return [ 382 | 'hex' => $color->getHex(), 383 | 'rgb' => $color->getRgb(), 384 | 'hsl' => $color->getHsl(), 385 | ]; 386 | } 387 | 388 | /** 389 | * Transforms a country element 390 | * 391 | * @param mixed $country 392 | * @return array 393 | */ 394 | protected function transformCountry(mixed $country): array 395 | { 396 | if (empty($country)) { 397 | return []; 398 | } 399 | 400 | return [ 401 | 'name' => $country->getName(), 402 | 'countryCode' => $country->getCountryCode(), 403 | 'threeLetterCode' => $country->getThreeLetterCode(), 404 | 'locale' => $country->getLocale(), 405 | 'currencyCode' => $country->getCurrencyCode(), 406 | 'timezones' => $country->getTimezones(), 407 | ]; 408 | } 409 | 410 | /** 411 | * Loads custom transformers via event. 412 | */ 413 | protected function registerCustomTransformers(): void 414 | { 415 | if ($this->hasEventHandlers(self::EVENT_REGISTER_FIELD_TRANSFORMERS)) { 416 | $event = new RegisterFieldTransformersEvent(); 417 | $this->trigger(self::EVENT_REGISTER_FIELD_TRANSFORMERS, $event); 418 | $this->customTransformers = $event->transformers; 419 | } 420 | } 421 | 422 | /** 423 | * @throws InvalidConfigException 424 | * @throws InvalidFieldException 425 | * @throws ImageTransformException 426 | */ 427 | protected function getTransformedCustomFieldData($isSingleRelation, $fieldValue, $fieldClass) 428 | { 429 | if ($isSingleRelation) { 430 | $transformedValue = $this->transformCustomField($fieldValue, $fieldClass); 431 | return is_array($transformedValue) && !empty($transformedValue) ? $transformedValue[0] : null; 432 | } 433 | 434 | return $this->transformCustomField($fieldValue, $fieldClass); 435 | } 436 | 437 | protected function getExcludedFieldClasses(): array 438 | { 439 | if (isset(QueryApi::getInstance()->getSettings()->excludeFieldClasses)) { 440 | return array_merge(Constants::EXCLUDED_FIELD_HANDLES, QueryApi::getInstance()->getSettings()->excludeFieldClasses); 441 | } 442 | 443 | return Constants::EXCLUDED_FIELD_HANDLES; 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /src/transformers/CategoryTransformer.php: -------------------------------------------------------------------------------- 1 | category = $category; 15 | } 16 | 17 | /** 18 | * @return array 19 | */ 20 | public function getTransformedData(array $predefinedFields = []): array 21 | { 22 | $transformedFields = $this->getTransformedFields(); 23 | 24 | return array_merge([ 25 | 'metadata' => $this->getMetaData(), 26 | 'title' => $this->category->title, 27 | 'slug' => $this->category->slug, 28 | 'uri' => $this->category->uri, 29 | ], $transformedFields); 30 | } 31 | 32 | /** 33 | * Retrieves metadata from the Category. 34 | * 35 | * @return array 36 | */ 37 | protected function getMetaData(): array 38 | { 39 | return [ 40 | 'id' => $this->category->id, 41 | ]; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/transformers/EntryTransformer.php: -------------------------------------------------------------------------------- 1 | entry = $entry; 19 | } 20 | 21 | /** 22 | * Transforms the Entry element into an array. 23 | * 24 | * @param array $predefinedFields 25 | * @return array 26 | * @throws InvalidConfigException 27 | * @throws InvalidFieldException 28 | * @throws ImageTransformException 29 | */ 30 | public function getTransformedData(array $predefinedFields = []): array 31 | { 32 | $data = ['metadata' => $this->getMetaData()]; 33 | 34 | // Not every entry has a section (matrix blocks) 35 | if ($this->entry->section && isset($this->entry->section->handle)) { 36 | $data['sectionHandle'] = $this->entry->section->handle; 37 | } 38 | 39 | $transformedFields = $this->getTransformedFields($predefinedFields); 40 | 41 | return array_merge($data, $transformedFields); 42 | } 43 | 44 | /** 45 | * Retrieves metadata from the Entry. 46 | * 47 | * @return array 48 | */ 49 | protected function getMetaData(): array 50 | { 51 | $isEntryWithSection = $this->entry->section !== null; 52 | return array_merge(parent::getMetaData(), [ 53 | 'entryType' => $this->entry->type->getHandle(), 54 | 'sectionId' => $isEntryWithSection ? $this->entry->section->getId() : null, 55 | 'siteId' => $this->entry->site->id, 56 | 'url' => $this->entry->getUrl(), 57 | 'slug' => $this->entry->slug, 58 | 'uri' => $this->entry->uri, 59 | 'fullUri' => Utils::getFullUriFromUrl($this->entry->getUrl()), 60 | 'status' => $this->entry->getStatus(), 61 | 'cpEditUrl' => $this->entry->getCpEditUrl(), 62 | ]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/transformers/TagTransformer.php: -------------------------------------------------------------------------------- 1 | tag = $tag; 15 | } 16 | 17 | /** 18 | * @param array $predefinedFields 19 | * @return array 20 | */ 21 | public function getTransformedData(array $predefinedFields = []): array 22 | { 23 | return [ 24 | 'metadata' => $this->getMetaData(), 25 | 'title' => $this->tag->title, 26 | 'slug' => $this->tag->slug, 27 | ]; 28 | } 29 | 30 | /** 31 | * Retrieves metadata from the Tag. 32 | * 33 | * @return array 34 | */ 35 | protected function getMetaData(): array 36 | { 37 | return [ 38 | 'id' => $this->tag->id, 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/transformers/UserTransformer.php: -------------------------------------------------------------------------------- 1 | user = $user; 15 | } 16 | 17 | /** 18 | * @return array 19 | */ 20 | public function getTransformedData(array $predefinedFields = []): array 21 | { 22 | $transformedFields = $this->getTransformedFields(); 23 | 24 | return array_merge([ 25 | 'metadata' => $this->getMetaData(), 26 | 'username' => $this->user->username, 27 | 'email' => $this->user->email, 28 | 'fullName' => $this->user->fullName, 29 | ], $transformedFields); 30 | } 31 | 32 | /** 33 | * Retrieves metadata from the User. 34 | * 35 | * @return array 36 | */ 37 | protected function getMetaData(): array 38 | { 39 | return [ 40 | 'id' => $this->user->id, 41 | 'status' => $this->user->status, 42 | 'cpEditUrl' => $this->user->cpEditUrl, 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/twigextensions/AuthHelper.php: -------------------------------------------------------------------------------- 1 | getSchemaComponents(...)), 23 | new TwigFunction('getAllSchemas', $this->getAllSchemas(...)), 24 | new TwigFunction('getAllTokens', $this->getAllTokens(...)), 25 | ]; 26 | } 27 | 28 | /** 29 | * Returns all schema components 30 | */ 31 | public function getSchemaComponents(): array 32 | { 33 | return QueryApi::getInstance()->schema->getSchemaComponents(); 34 | } 35 | 36 | /** 37 | * Returns all schemas 38 | */ 39 | public function getAllSchemas(): array 40 | { 41 | $schemas = QueryApi::getInstance()->schema->getSchemas(); 42 | 43 | return array_map(function($schema) { 44 | return [ 45 | 'id' => $schema->id, 46 | 'title' => $schema->name, 47 | 'url' => UrlHelper::url('query-api/schemas/' . $schema->id), 48 | 'usage' => QueryApi::getInstance()->token->getSchemaUsageInTokensAmount($schema->id), 49 | ]; 50 | }, $schemas); 51 | } 52 | 53 | public function getAllTokens(): array 54 | { 55 | $tokens = QueryApi::getInstance()->token->getTokens(); 56 | 57 | return array_map(/** 58 | * @throws InvalidConfigException 59 | */ function($schema) { 60 | return [ 61 | 'id' => $schema->id, 62 | 'title' => $schema->name, 63 | 'status' => $schema->enabled, 64 | 'dateCreated' => (new Formatter())->asDate($schema->dateCreated), 65 | 'dateUpdated' => (new Formatter())->asDate($schema->dateUpdated), 66 | 'url' => UrlHelper::url('query-api/tokens/' . $schema->id), 67 | ]; 68 | }, $tokens); 69 | } 70 | } 71 | --------------------------------------------------------------------------------