From 5a5739a4662d65cce46f13e3b1e0f9382939b44c Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 9 Nov 2024 14:18:29 -0500 Subject: [PATCH 01/10] Implements an API for retrieving models --- .../config/install/rest.resource.model_v1.yml | 17 ++ web/modules/mof/mof.module | 19 +- web/modules/mof/src/ModelEvaluator.php | 2 +- .../mof/src/ModelEvaluatorInterface.php | 4 +- web/modules/mof/src/ModelSerializer.php | 50 ++-- .../mof/src/ModelSerializerInterface.php | 37 +++ .../rest/resource/ModelEntityResourceV1.php | 249 ++++++++++++++++++ 7 files changed, 346 insertions(+), 32 deletions(-) create mode 100644 web/modules/mof/config/install/rest.resource.model_v1.yml create mode 100644 web/modules/mof/src/ModelSerializerInterface.php create mode 100644 web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php diff --git a/web/modules/mof/config/install/rest.resource.model_v1.yml b/web/modules/mof/config/install/rest.resource.model_v1.yml new file mode 100644 index 0000000..ebaee0c --- /dev/null +++ b/web/modules/mof/config/install/rest.resource.model_v1.yml @@ -0,0 +1,17 @@ +langcode: en +status: true +dependencies: + module: + - mof + - serialization + - user +id: model_v1 +plugin_id: model_v1 +granularity: resource +configuration: + methods: + - GET + formats: + - json + authentication: + - cookie diff --git a/web/modules/mof/mof.module b/web/modules/mof/mof.module index de6abcc..88088bc 100644 --- a/web/modules/mof/mof.module +++ b/web/modules/mof/mof.module @@ -9,8 +9,9 @@ declare(strict_types=1); use Drupal\Core\Render\Element; use Drupal\Core\Entity\FieldableEntityInterface; -use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Database\Query\AlterableInterface; use Drupal\user\UserInterface; /** @@ -87,3 +88,19 @@ function mof_user_predelete(UserInterface $account): void { } } +/** + * Implements hook_query_TAG_alter(). + * Adds a model entity access check to model storage queries. + * Ensure the model is approved for non-admin users. + */ +function mof_query_model_access_alter(AlterableInterface $query) { + $user = \Drupal::currentUser(); + + if (!$user->hasRole('admin') && (int)$user->id() !== 1) { + $base_table = $query->getTables()['base_table']['alias']; + $query->leftJoin('model_field_data', 'mfd', "mfd.id = $base_table.id"); + $query->condition('mfd.status', 'approved', '='); + } + + return $query; +} diff --git a/web/modules/mof/src/ModelEvaluator.php b/web/modules/mof/src/ModelEvaluator.php index 09a84c7..83046ab 100644 --- a/web/modules/mof/src/ModelEvaluator.php +++ b/web/modules/mof/src/ModelEvaluator.php @@ -274,7 +274,7 @@ private function isOpenSourceLicense(int $cid): bool { * @return float * Progress percentage */ - private function getProgress(int $class): float { + public function getProgress(int $class): float { $required = $this->getRequiredComponents(); $evaluate = $this->evaluate(); diff --git a/web/modules/mof/src/ModelEvaluatorInterface.php b/web/modules/mof/src/ModelEvaluatorInterface.php index afeedd8..81a42a1 100644 --- a/web/modules/mof/src/ModelEvaluatorInterface.php +++ b/web/modules/mof/src/ModelEvaluatorInterface.php @@ -1,6 +1,4 @@ -getOwner(); $data = [ @@ -71,32 +68,31 @@ private function processModel(ModelInterface $model): array { } /** - * Return a YAML representation of the model. - * - * @param \Drupal\mof\ModelInterface $model - * The model to convert to YAML. - * @return string - * A string representing the model in YAML format. + * {@inheritdoc} */ public function toYaml(ModelInterface $model): string { - return Yaml::encode($this->processModel($model)); + try { + return Yaml::encode($this->normalize($model)); + } + catch (InvalidDataTypeException $e) { + // @todo Log exception. + } } /** - * Return a JSON representation of the model. - * - * @param \Drupal\mof\ModelInterface $model - * The model to convert to JSON. - * @return string - * A string representing the model in JSON format. + * {@inheritdoc} */ public function toJson(ModelInterface $model): string { - return $this - ->serializer - ->serialize($this - ->processModel($model), 'json', [ - 'json_encode_options' => \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES - ]); + try { + return $this + ->serializer + ->serialize($this->normalize($model), 'json', [ + 'json_encode_options' => \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES + ]); + } + catch (UnsupportedFormatException $e) { + // @todo Log exception. + } } } diff --git a/web/modules/mof/src/ModelSerializerInterface.php b/web/modules/mof/src/ModelSerializerInterface.php new file mode 100644 index 0000000..3b43f39 --- /dev/null +++ b/web/modules/mof/src/ModelSerializerInterface.php @@ -0,0 +1,37 @@ +modelStorage = $entity_type_manager->getStorage('model'); + } + + /** + * {@inheritdoc} + */ + public static function create( + ContainerInterface $container, + array $configuration, + $plugin_id, + $plugin_definition + ) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->getParameter('serializer.formats'), + $container->get('logger.factory')->get('api/v1/model'), + $container->get('entity_type.manager'), + $container->get('model_serializer'), + $container->get('model_evaluator') + ); + } + + /** + * Responds to model_v1 GET requests. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The incoming request. + * @param \Drupal\mof\ModelInterface $model + * The model entity. If NULL a model collection is returned. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * The response containing model data. + */ + public function get(Request $request, ?ModelInterface $model = NULL): CacheableJsonResponse { + return ($model) ? $this->getModel($model) : $this->listModels($request); + } + + /** + * Retrieve a single model. + * + * This method returns a cacheable JSON response + * containing a model with classification information. + * + * @param \Drupal\mof\ModelInterface $model + * The model entity. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * The response containing the model. + */ + protected function getModel(ModelInterface $model): CacheableJsonResponse { + $json = $this->classify($model); + $response = new CacheableJsonResponse($json); + $response->addCacheableDependency($model); + return $response; + } + + /** + * Retrieve a collection of models. + * + * This method returns a cacheable JSON response containing + * a list of models with pagination details. + * + * Query parameters: + * - page (int): Current page number (1-indexed). + * Defaults to 1 if not specified. + * - limit (int): The maximum number of models to display per page. + * Defaults to 100 if not specified. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The incoming request. + * + * @return \Drupal\Core\Cache\CacheableJsonResponse + * The response containing models and pager details. + */ + protected function listModels(Request $request): CacheableJsonResponse { + $collection = ['pager' => [], 'models' => []]; + + $page = max(1, (int) $request->query->get('page', 1)); + $limit = max(1, (int) $request->query->get('limit', 100)); + + $pager = new Pager($this->getModelCount(), $limit, $page); + $collection['pager']['total_items'] = $pager->getTotalItems(); + $collection['pager']['total_pages'] = $pager->getTotalPages(); + $collection['pager']['current_page'] = $page; + + $models = $this + ->modelStorage + ->getQuery() + ->accessCheck(TRUE) + ->sort('id', 'ASC') + ->range(($page - 1) * $limit, $limit) + ->execute(); + + foreach ($this + ->modelStorage + ->loadMultiple($models) as $model) { + + $collection['models'][] = $this->classify($model); + } + + $cache_metadata = new CacheableMetadata(); + $cache_metadata->setCacheMaxAge(3600); + $cache_metadata->addCacheContexts(['url.query_args:limit', 'url.query_args:page']); + + $response = new CacheableJsonResponse($collection); + $response->addCacheableDependency($cache_metadata); + return $response; + } + + /** + * Return a total number of models in the database. + * + * @return int + * The number of models. + */ + protected function getModelCount(): int { + return $this + ->modelStorage + ->getQuery() + ->accessCheck(TRUE) + ->count() + ->execute(); + } + + /** + * Evaluate and add classification information to model. + * + * @param \Drupal\mof\ModelInterface $model + * The model entity. + * + * @return array + * An array containing model data suitable for JSON encoding. + */ + protected function classify(ModelInterface $model): array { + $evaluator = $this->modelEvaluator->setModel($model); + + $json = $this->modelSerializer->toJson($model); + $json = json_decode($json, TRUE); + + $class = $evaluator->getClassification(FALSE); + $json['classification']['id'] = $model->id(); + $json['classification']['class'] = $class; + $json['classification']['label'] = $evaluator->getClassLabel($class); + + for ($i = 1; $i <= 3; ++$i) { + $json['classification']['progress'][$i] = $evaluator->getProgress($i); + } + + return $json; + } + + /** + * {@inheritdoc} + */ + public function routes() { + $routes = new RouteCollection(); + $definition = $this->getPluginDefinition(); + + foreach ($definition['uri_paths'] as $key => $uri) { + $route = $this->getBaseRoute($uri, 'GET'); + + if (strstr($uri, '{model}')) { + $route->setOption('parameters', ['model' => ['type' => 'entity:model']]); + $route->setRequirement('_entity_access', 'model.view'); + } + + $routes->add("{$this->pluginId}.{$key}.GET", $route); + } + + return $routes; + } + +} + From e7fb865f6e7d518046f06646e6db73c41cb17b18 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 9 Nov 2024 18:34:47 -0500 Subject: [PATCH 02/10] Implements rate limiter by IP --- web/modules/mof/mof.services.yml | 7 ++ .../rest/resource/ModelEntityResourceV1.php | 3 +- web/modules/mof/src/RateLimitMiddleware.php | 81 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 web/modules/mof/src/RateLimitMiddleware.php diff --git a/web/modules/mof/mof.services.yml b/web/modules/mof/mof.services.yml index ddc95cc..397014d 100644 --- a/web/modules/mof/mof.services.yml +++ b/web/modules/mof/mof.services.yml @@ -1,5 +1,12 @@ services: + mof.api.rate_limiter: + class: Drupal\mof\RateLimitMiddleware + arguments: + - '@cache.default' + tags: + - { name: http_middleware, priority: 400 } + license_handler: class: Drupal\mof\LicenseHandler arguments: diff --git a/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php b/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php index 7373e81..907ecb7 100644 --- a/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php +++ b/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php @@ -213,7 +213,6 @@ protected function classify(ModelInterface $model): array { $json = json_decode($json, TRUE); $class = $evaluator->getClassification(FALSE); - $json['classification']['id'] = $model->id(); $json['classification']['class'] = $class; $json['classification']['label'] = $evaluator->getClassLabel($class); @@ -221,7 +220,7 @@ protected function classify(ModelInterface $model): array { $json['classification']['progress'][$i] = $evaluator->getProgress($i); } - return $json; + return ['id' => $model->id(), ...$json]; } /** diff --git a/web/modules/mof/src/RateLimitMiddleware.php b/web/modules/mof/src/RateLimitMiddleware.php new file mode 100644 index 0000000..487c4df --- /dev/null +++ b/web/modules/mof/src/RateLimitMiddleware.php @@ -0,0 +1,81 @@ +isApiRequest($request)) { + return $this->http->handle($request, $type, $catch); + } + return $this->rateLimit($request) ?: $this->http->handle($request, $type, $catch); + } + + /** + * Checks if the request path is for an API endpoint. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request. + * + * @return bool + * TRUE if the request path begins with "/api", FALSE otherwise. + */ + private function isApiRequest(Request $request): bool { + return strpos($request->getPathInfo(), '/api') === 0; + } + + /** + * Applies rate limiting based on client IP address. + * + * Checks the number of requests made by the IP in the last interval. + * If the count exceeds the limit, returns a JSON error response. + * Otherwise, increments the request count and stores it in the cache. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse|null + * A JSON response with an error if the rate limit is exceeded, or NULL if allowed. + */ + private function rateLimit(Request $request): ?JsonResponse { + $cache_key = 'rate_limit:' . $request->getClientIp(); + $request_count = $this->cache->get($cache_key)->data ?? 1; + + if ($request_count > self::LIMIT) { + return new JsonResponse(['error' => 'Rate limit exceeded. Try again later.'], Response::HTTP_TOO_MANY_REQUESTS); + } + + $this->cache->set($cache_key, $request_count + 1, time() + self::INTERVAL); + return NULL; + } + +} + From f6a4f2fe445574d19f2dbef56f899e2c5c65e681 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 9 Nov 2024 20:37:15 -0500 Subject: [PATCH 03/10] Enable API requirements on update --- .github/workflows/deploy.yml | 5 +++-- web/modules/mof/mof.install | 24 ++++++++++++++++++++++++ web/modules/mof/mof.module | 4 +--- 3 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 web/modules/mof/mof.install diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bd31f24..f5242cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -85,7 +85,7 @@ jobs: --exclude '.github' \ --exclude '.env' \ --exclude 'tests' \ - --exclude 'config' \ + --exclude 'config/sync' \ --exclude 'models/' \ --exclude 'web/sites/*/files' \ --exclude 'web/libraries' \ @@ -97,8 +97,9 @@ jobs: echo "Deploying to $DEPLOY_PATH" cd $DEPLOY_PATH composer install --no-dev --no-progress --optimize-autoloader - ./vendor/bin/drush cr ./vendor/bin/drush updb -y + ./vendor/bin/drush cim --partial --source=modules/mof/config/install -y + ./vendor/bin/drush cr EOF - name: Revoke IP address diff --git a/web/modules/mof/mof.install b/web/modules/mof/mof.install new file mode 100644 index 0000000..6929c1d --- /dev/null +++ b/web/modules/mof/mof.install @@ -0,0 +1,24 @@ +install(['rest', 'config']); + + // Enable MOT REST plugin. + \Drupal::service('config.installer')->installDefaultConfig('module', 'mof'); + + // Add permission to use REST API for anonymous and authenticated users. + foreach (['anonymous', 'authenticated'] as $role_name) { + $role = \Drupal\user\Entity\Role::load($role_name); + if ($role) { + $role->grantPermission('restful get model_v1'); + $role->save(); + } + } +} + diff --git a/web/modules/mof/mof.module b/web/modules/mof/mof.module index 88088bc..0cbef7e 100644 --- a/web/modules/mof/mof.module +++ b/web/modules/mof/mof.module @@ -1,6 +1,4 @@ - Date: Sat, 9 Nov 2024 20:57:11 -0500 Subject: [PATCH 04/10] Enable cache modules --- web/modules/mof/mof.install | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/modules/mof/mof.install b/web/modules/mof/mof.install index 6929c1d..755eab7 100644 --- a/web/modules/mof/mof.install +++ b/web/modules/mof/mof.install @@ -7,7 +7,12 @@ */ function mof_update_10201() { // Enable REST and config management module. - \Drupal::service('module_installer')->install(['rest', 'config']); + \Drupal::service('module_installer')->install([ + 'rest', + 'config', + 'dynamic_page_cache', + 'page_cache' + ]); // Enable MOT REST plugin. \Drupal::service('config.installer')->installDefaultConfig('module', 'mof'); From 595f07560e3f56ccce8a1daaae0d5681d2081a73 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 27 Nov 2024 14:48:56 -0500 Subject: [PATCH 05/10] Implement custom logger channel; log exceptions --- web/modules/mof/mof.services.yml | 5 +++ .../mof/src/Controller/ModelController.php | 36 ++++++++++++------- web/modules/mof/src/ModelSerializer.php | 10 ++++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/web/modules/mof/mof.services.yml b/web/modules/mof/mof.services.yml index 397014d..8ee4b36 100644 --- a/web/modules/mof/mof.services.yml +++ b/web/modules/mof/mof.services.yml @@ -1,5 +1,9 @@ services: + logger.channel.mot: + parent: logger.channel_base + arguments: ['mot'] + mof.api.rate_limiter: class: Drupal\mof\RateLimitMiddleware arguments: @@ -31,6 +35,7 @@ services: - '@serializer' - '@model_evaluator' - '@component.manager' + - '@logger.channel.mot' model_validator: class: Drupal\mof\ModelValidator diff --git a/web/modules/mof/src/Controller/ModelController.php b/web/modules/mof/src/Controller/ModelController.php index 43dbdfc..daffc7e 100644 --- a/web/modules/mof/src/Controller/ModelController.php +++ b/web/modules/mof/src/Controller/ModelController.php @@ -120,13 +120,19 @@ public function badge(ModelInterface $model, int $class): Response { * Return a yaml representation of the model. */ public function yaml(ModelInterface $model): Response { - $yaml = $this->modelSerializer->toYaml($model); - $response = new Response(); - $response->setContent($yaml); - $response->headers->set('Content-Type', 'application/yaml'); - $response->headers->set('Content-Length', (string)strlen($yaml)); - $response->headers->set('Content-Disposition', 'attachment; filename="mof.yml"'); + + try { + $yaml = $this->modelSerializer->toYaml($model); + $response->setContent($yaml); + $response->headers->set('Content-Type', 'application/yaml'); + $response->headers->set('Content-Length', (string)strlen($yaml)); + $response->headers->set('Content-Disposition', 'attachment; filename="mof.yml"'); + } + catch (\RuntimeException $e) { + $response->setContent($e->getMessage()); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } return $response; } @@ -135,13 +141,19 @@ public function yaml(ModelInterface $model): Response { * Return a json file representation of the model. */ public function json(ModelInterface $model): Response { - $json = $this->modelSerializer->toJson($model); - $response = new Response(); - $response->setContent($json); - $response->headers->set('Content-Type', 'application/json'); - $response->headers->set('Content-Length', (string)strlen($json)); - $response->headers->set('Content-Disposition', 'attachment; filename="mof.json"'); + + try { + $json = $this->modelSerializer->toJson($model); + $response->setContent($json); + $response->headers->set('Content-Type', 'application/json'); + $response->headers->set('Content-Length', (string)strlen($json)); + $response->headers->set('Content-Disposition', 'attachment; filename="mof.json"'); + } + catch (\RuntimeException $e) { + $response->setContent($e->getMessage()); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } return $response; } diff --git a/web/modules/mof/src/ModelSerializer.php b/web/modules/mof/src/ModelSerializer.php index 0fd0fbc..9bdae70 100644 --- a/web/modules/mof/src/ModelSerializer.php +++ b/web/modules/mof/src/ModelSerializer.php @@ -10,6 +10,7 @@ use Drupal\Component\Serialization\Exception\InvalidDataTypeException; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Psr\Log\LoggerInterface; /** * ModelSerializer class. @@ -22,7 +23,8 @@ final class ModelSerializer implements ModelSerializerInterface { public function __construct( private readonly SerializerInterface $serializer, private readonly ModelEvaluatorInterface $modelEvaluator, - private readonly ComponentManagerInterface $componentManager + private readonly ComponentManagerInterface $componentManager, + private readonly LoggerInterface $logger ) {} /** @@ -82,7 +84,8 @@ public function toYaml(ModelInterface $model): string { return Yaml::encode($this->normalize($model)); } catch (InvalidDataTypeException $e) { - // @todo Log exception. + $this->logger->error('@exception', ['@exception' => $e->getMessage()]); + throw new \RuntimeException('Failed to convert model to YAML.', $e->getCode(), $e); } } @@ -98,7 +101,8 @@ public function toJson(ModelInterface $model): string { ]); } catch (UnsupportedFormatException $e) { - // @todo Log exception. + $this->logger->error('@exception', ['@exception' => $e->getMessage()]); + throw new \RuntimeException('Failed to convert model to JSON.', $e->getCode(), $e); } } From 7668ad4a6d6dbf0ef3e107a1d2211100932b5d3f Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 27 Nov 2024 16:49:01 -0500 Subject: [PATCH 06/10] Log rate limit hits --- web/modules/mof/mof.services.yml | 1 + web/modules/mof/src/RateLimitMiddleware.php | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/web/modules/mof/mof.services.yml b/web/modules/mof/mof.services.yml index 8ee4b36..c5745e9 100644 --- a/web/modules/mof/mof.services.yml +++ b/web/modules/mof/mof.services.yml @@ -8,6 +8,7 @@ services: class: Drupal\mof\RateLimitMiddleware arguments: - '@cache.default' + - '@logger.channel.mot' tags: - { name: http_middleware, priority: 400 } diff --git a/web/modules/mof/src/RateLimitMiddleware.php b/web/modules/mof/src/RateLimitMiddleware.php index 487c4df..1bbd3cc 100644 --- a/web/modules/mof/src/RateLimitMiddleware.php +++ b/web/modules/mof/src/RateLimitMiddleware.php @@ -7,6 +7,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; +use Psr\Log\LoggerInterface; /** * Middleware to rate-limit API requests by IP address. @@ -26,7 +27,8 @@ final class RateLimitMiddleware implements HttpKernelInterface { */ public function __construct( private readonly HttpKernelInterface $http, - private readonly CacheBackendInterface $cache + private readonly CacheBackendInterface $cache, + private readonly LoggerInterface $logger ) {} /** @@ -70,6 +72,7 @@ private function rateLimit(Request $request): ?JsonResponse { $request_count = $this->cache->get($cache_key)->data ?? 1; if ($request_count > self::LIMIT) { + $this->logger->notice('Rate limit exceeded for @ip', ['@ip' => $request->getClientIp()]); return new JsonResponse(['error' => 'Rate limit exceeded. Try again later.'], Response::HTTP_TOO_MANY_REQUESTS); } From 4ef091e0677b6990cd647bfddc824058b6b82c2d Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 27 Nov 2024 17:24:06 -0500 Subject: [PATCH 07/10] Add error code to json response --- web/modules/mof/src/RateLimitMiddleware.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/modules/mof/src/RateLimitMiddleware.php b/web/modules/mof/src/RateLimitMiddleware.php index 1bbd3cc..31bc501 100644 --- a/web/modules/mof/src/RateLimitMiddleware.php +++ b/web/modules/mof/src/RateLimitMiddleware.php @@ -73,7 +73,9 @@ private function rateLimit(Request $request): ?JsonResponse { if ($request_count > self::LIMIT) { $this->logger->notice('Rate limit exceeded for @ip', ['@ip' => $request->getClientIp()]); - return new JsonResponse(['error' => 'Rate limit exceeded. Try again later.'], Response::HTTP_TOO_MANY_REQUESTS); + return new JsonResponse(['error' => [ + 'code' => Response::HTTP_TOO_MANY_REQUESTS, + 'message' => 'Rate limit exceeded. Try again later.']], Response::HTTP_TOO_MANY_REQUESTS); } $this->cache->set($cache_key, $request_count + 1, time() + self::INTERVAL); From 0eb8efc5c919385c780ea12e010d257d49dfdaf6 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 27 Nov 2024 17:46:28 -0500 Subject: [PATCH 08/10] Add API docs --- API.md | 89 +++++++++++++++++++ .../mof/src/ModelSerializerInterface.php | 4 + 2 files changed, 93 insertions(+) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..e01a7ad --- /dev/null +++ b/API.md @@ -0,0 +1,89 @@ +# API + +## Overview + +This API provides read-only access to a collection of models. It is open to the public and does not require an API key for access. + +### Response Format + +- All responses are in **JSON** format. + +### Rate Limiting + +- **Limit**: 10,000 requests per hour per IP address. +- Exceeding this limit will result in temporary rate-limiting. +- Rate-limited responses include the following structure: +```json +{ + "error": { + "code": 429, + "message": "Rate limit exceeded. Try again later." + } +} +``` + +--- + +## Endpoints + +### **GET /api/v1/models** + +Lists all models with pagination. + +#### Query Parameters: +- `page` (optional): The page number to retrieve (default: `1`). Pages are 1-indexed. +- `limit` (optional): The number of models per page (default: `100`). + +#### Example Request: +```http +GET /api/v1/models?page=2&limit=50 +``` + +#### Example Response: +```json +{ + "pager": { + "total_items": 235, + "total_pages": 5, + "current_page": 2 + }, + "models": [...] +} +``` + +--- + +### **GET /api/v1/model/{model}** + +Retrieves details of a specific model. + +#### Path Parameters: +- `model`: The ID of the model to retrieve. Model IDs can be found using the `/api/v1/models` endpoint. + +#### Example Request: +```http +GET /api/v1/model/1130 +``` + +#### Example Response: +```json +{ + "id": "1130", + "framework": { + "name": "Model Openness Framework", + "version": "1.0", + "date": "2024-12-15" + }, + "release": {...}, + "classification": { + "class": 1, + "label": "Class I - Open Science", + "progress": { + "1": 100, + "2": 100, + "3": 100 + } + } +} +``` + diff --git a/web/modules/mof/src/ModelSerializerInterface.php b/web/modules/mof/src/ModelSerializerInterface.php index 3b43f39..d03d9d0 100644 --- a/web/modules/mof/src/ModelSerializerInterface.php +++ b/web/modules/mof/src/ModelSerializerInterface.php @@ -21,6 +21,8 @@ public function normalize(ModelInterface $model): array; * The model to convert to YAML. * @return string * A formatted string representing the model in YAML. + * @throws \RuntimeException + * Rethrown in catch block if an InvalidDataTypeException occurs. */ public function toYaml(ModelInterface $model): string; @@ -31,6 +33,8 @@ public function toYaml(ModelInterface $model): string; * The model to convert to JSON. * @return string * A formatted string representing the model in JSON. + * @throws \RuntimeException + * Rethrown in catch block if an UnsupportedFormatException occurs. */ public function toJson(ModelInterface $model): string; From 78ba153e9b1ea35ffcc5b6e075c3d297f2a8c13f Mon Sep 17 00:00:00 2001 From: Greg Date: Thu, 28 Nov 2024 16:18:54 -0500 Subject: [PATCH 09/10] Handle API exception events --- web/modules/mof/mof.services.yml | 5 ++ .../ApiExceptionSubscriber.php | 47 +++++++++++++++++++ .../rest/resource/ModelEntityResourceV1.php | 1 + 3 files changed, 53 insertions(+) create mode 100644 web/modules/mof/src/EventSubscriber/ApiExceptionSubscriber.php diff --git a/web/modules/mof/mof.services.yml b/web/modules/mof/mof.services.yml index c5745e9..2812273 100644 --- a/web/modules/mof/mof.services.yml +++ b/web/modules/mof/mof.services.yml @@ -12,6 +12,11 @@ services: tags: - { name: http_middleware, priority: 400 } + mof.api.exception_subscriber: + class: Drupal\mof\EventSubscriber\ApiExceptionSubscriber + tags: + - { name: event_subscriber } + license_handler: class: Drupal\mof\LicenseHandler arguments: diff --git a/web/modules/mof/src/EventSubscriber/ApiExceptionSubscriber.php b/web/modules/mof/src/EventSubscriber/ApiExceptionSubscriber.php new file mode 100644 index 0000000..1752b79 --- /dev/null +++ b/web/modules/mof/src/EventSubscriber/ApiExceptionSubscriber.php @@ -0,0 +1,47 @@ + 'onException']; + } + + /** + * Handles the exception event. + * + * @param \Symfony\Component\HttpKernel\Event\ExceptionEvent $event + * The exception event. + */ + public function onException(ExceptionEvent $event) { + $request = $event->getRequest(); + + // Only handle exceptions for API hits. + if (strpos($request->getPathInfo(), '/api') === 0) { + $exception = $event->getThrowable(); + + $json = [ + 'error' => [ + 'code' => $exception->getStatusCode(), + 'message' => $exception->getMessage(), + ]]; + + $response = new JsonResponse($json, $exception->getStatusCode()); + $event->setResponse($response); + } + } + +} + diff --git a/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php b/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php index 907ecb7..e102cdc 100644 --- a/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php +++ b/web/modules/mof/src/Plugin/rest/resource/ModelEntityResourceV1.php @@ -236,6 +236,7 @@ public function routes() { if (strstr($uri, '{model}')) { $route->setOption('parameters', ['model' => ['type' => 'entity:model']]); $route->setRequirement('_entity_access', 'model.view'); + $route->setRequirement('model', '\d+'); } $routes->add("{$this->pluginId}.{$key}.GET", $route); From 47cc3caa727c1299da4df96567f65e2ece8a91a3 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 29 Nov 2024 13:22:11 -0500 Subject: [PATCH 10/10] Add drupal:rest dependency and desc/package to info yml --- web/modules/mof/mof.info.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/modules/mof/mof.info.yml b/web/modules/mof/mof.info.yml index 482f04e..f3342f1 100644 --- a/web/modules/mof/mof.info.yml +++ b/web/modules/mof/mof.info.yml @@ -1,8 +1,9 @@ name: 'Model Openness Framework' type: module -description: '@todo Add description.' -package: '@todo Add package' +description: 'Implements the model openness framework in Drupal' +package: 'Model Openness Tool' core_version_requirement: ^10 dependencies: - drupal:options + - drupal:rest - social_auth_github:social_auth_github