From e64001d00604bcf587ec5e9a631282f212df450d Mon Sep 17 00:00:00 2001 From: Sebastian Janik Date: Wed, 22 Jun 2022 10:48:58 +0000 Subject: [PATCH] feat(api): add rest api with podcasts read endpoints relates to #210 --- .env.example | 5 + .gitlab-ci.yml | 12 + app/Config/Autoload.php | 1 + app/Config/Filters.php | 2 + .../Seeds/FakeSinglePodcastApiSeeder.php | 141 ++++++++ modules/Api/Rest/V1/Config/Api.php | 19 + modules/Api/Rest/V1/Config/Routes.php | 21 ++ modules/Api/Rest/V1/Config/Services.php | 20 ++ .../V1/Controllers/ExceptionController.php | 19 + .../Rest/V1/Controllers/PodcastController.php | 42 +++ modules/Api/Rest/V1/Core/Exceptions.php | 32 ++ modules/Api/Rest/V1/Filters/ApiFilter.php | 25 ++ modules/Api/Rest/V1/podcast.json | 328 ++++++++++++++++++ phpunit.xml.dist | 9 +- tests/modules/Api/Rest/V1/PodcastTest.php | 109 ++++++ 15 files changed, 780 insertions(+), 5 deletions(-) create mode 100644 app/Database/Seeds/FakeSinglePodcastApiSeeder.php create mode 100644 modules/Api/Rest/V1/Config/Api.php create mode 100644 modules/Api/Rest/V1/Config/Routes.php create mode 100644 modules/Api/Rest/V1/Config/Services.php create mode 100644 modules/Api/Rest/V1/Controllers/ExceptionController.php create mode 100644 modules/Api/Rest/V1/Controllers/PodcastController.php create mode 100644 modules/Api/Rest/V1/Core/Exceptions.php create mode 100644 modules/Api/Rest/V1/Filters/ApiFilter.php create mode 100644 modules/Api/Rest/V1/podcast.json create mode 100644 tests/modules/Api/Rest/V1/PodcastTest.php diff --git a/.env.example b/.env.example index 578e0e0c..2844aa83 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,8 @@ cache.handler="file" # cache.redis.password=null # cache.redis.port=6379 # cache.redis.database=0 + +#REST API configuration +#-------------------------------------------------------------------- +# 0/1 Disabled/Enabled +REST_API_ENABLED=1 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b344654b..578045a9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -65,7 +65,19 @@ lint-js: tests: stage: quality + services: + - mariadb + variables: + MYSQL_DATABASE: "tests" + MYSQL_ROOT_PASSWORD: "R00Tp4ssW0RD" + MYSQL_USER: "tests_user" + MYSQL_PASSWORD: "password" + script: + - apt-get install -y mariadb-client libmariadb-dev + + - echo "SHOW DATABASES;" | mysql --user=root --password="$MYSQL_ROOT_PASSWORD" --host=mariadb "$MYSQL_DATABASE" + # run phpunit without code coverage # TODO: add code coverage - vendor/bin/phpunit --no-coverage diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index f68f50de..8c5f6f01 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -50,6 +50,7 @@ class Autoload extends AutoloadConfig 'Modules\Install' => ROOTPATH . 'modules/Install/', 'Modules\Fediverse' => ROOTPATH . 'modules/Fediverse/', 'Modules\WebSub' => ROOTPATH . 'modules/WebSub/', + 'Modules\Api\Rest\V1' => ROOTPATH . 'modules/Api/Rest/V1', 'Config' => APPPATH . 'Config/', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', diff --git a/app/Config/Filters.php b/app/Config/Filters.php index f7088509..8d893b74 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -10,6 +10,7 @@ use CodeIgniter\Filters\DebugToolbar; use CodeIgniter\Filters\Honeypot; use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\SecureHeaders; +use Modules\Api\Rest\V1\Filters\ApiFilter; use Modules\Auth\Filters\PermissionFilter; use Modules\Fediverse\Filters\AllowCorsFilter; use Modules\Fediverse\Filters\FediverseFilter; @@ -34,6 +35,7 @@ class Filters extends BaseConfig 'permission' => PermissionFilter::class, 'fediverse' => FediverseFilter::class, 'allow-cors' => AllowCorsFilter::class, + 'rest-api' => ApiFilter::class, ]; /** diff --git a/app/Database/Seeds/FakeSinglePodcastApiSeeder.php b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php new file mode 100644 index 00000000..b2bd6a3d --- /dev/null +++ b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php @@ -0,0 +1,141 @@ + + */ + public static function cover(): array + { + return [ + 'id' => 1, + 'file_path' => 'podcasts/Handle/cover.jpg', + 'file_size' => 400000, + 'file_mimetype' => 'image/jpeg', + 'file_metadata' => '{"FILE":{"FileName":"cover.jpg","FileDateTime":1654861723,"FileSize":468541,"FileType":2,"MimeType":"image\/jpeg","SectionsFound":"COMMENT"},"COMPUTED":{"html":"width=\"1400\" height=\"1400\"","Height":1400,"Width":1400,"IsColor":1},"COMMENT":["CREATOR: gd-jpeg v1.0 (using IJG JPEG v62), quality = 90\n"],"sizes":{"tiny":{"width":40,"height":40,"mimetype":"image\/webp","extension":"webp"},"thumbnail":{"width":150,"height":150,"mimetype":"image\/webp","extension":"webp"},"medium":{"width":320,"height":320,"mimetype":"image\/webp","extension":"webp"},"large":{"width":1024,"height":1024,"mimetype":"image\/webp","extension":"webp"},"feed":{"width":1400,"height":1400},"id3":{"width":500,"height":500},"og":{"width":1200,"height":1200},"federation":{"width":400,"height":400},"webmanifest192":{"width":192,"height":192,"mimetype":"image\/png","extension":"png"},"webmanifest512":{"width":512,"height":512,"mimetype":"image\/png","extension":"png"}}}', + 'type' => 'image', + 'description' => null, + 'language_code' => null, + 'uploaded_by' => 1, + 'updated_by' => 1, + 'uploaded_at' => '2022-06-13 8:00:00', + 'updated_at' => '2022-06-13 8:00:00', + ]; + } + + /** + * @return array + */ + public static function banner(): array + { + return [ + 'id' => 2, + 'file_path' => 'podcasts/Handle/banner.jpg', + 'file_size' => 400000, + 'file_mimetype' => 'image/jpeg', + 'file_metadata' => '{"FILE":{"FileName":"banner.jpg","FileDateTime":1654861724,"FileSize":98209,"FileType":2,"MimeType":"image\/jpeg","SectionsFound":""},"COMPUTED":{"html":"width=\"1500\" height=\"500\"","Height":500,"Width":1500,"IsColor":1},"sizes":{"small":{"width":320,"height":128,"mimetype":"image\/webp","extension":"webp"},"medium":{"width":960,"height":320,"mimetype":"image\/webp","extension":"webp"},"federation":{"width":1500,"height":500}}}', + 'type' => 'image', + 'description' => null, + 'language_code' => null, + 'uploaded_by' => 1, + 'updated_by' => 1, + 'uploaded_at' => '2022-06-13 8:00:00', + 'updated_at' => '2022-06-13 8:00:00', + ]; + } + + /** + * @return array + */ + public static function actor(): array + { + return [ + 'id' => 1, + 'uri' => getenv('app_baseURL') . '@Handle', + 'username' => 'Handle', + 'domain' => getenv('app_baseURL'), + 'private_key' => 'private_key', + 'public_key' => 'public_key', + 'display_name' => 'Title', + 'summary' => '

description

', + 'avatar_image_url' => getenv('app_baseURL') . 'media/podcasts/Handle', + 'avatar_image_mimetype' => 'image/webp', + 'cover_image_url' => null, + 'cover_image_mimetype' => null, + 'inbox_url' => getenv('app_baseURL') . '@Handle/inbox', + 'outbox_url' => getenv('app_baseURL') . '@Handle/outbox', + 'followers_url' => getenv('app_baseURL') . '@Handle/followers', + 'followers_count' => 0, + 'posts_count' => 0, + 'is_blocked' => 0, + 'created_at' => '2022-06-13 8:00:00', + 'updated_at' => '2022-06-13 8:00:00', + ]; + } + + /** + * @return array + */ + public static function podcast(): array + { + return [ + 'id' => 1, + 'guid' => '0d341200-0234-5de7-99a6-a7d02bea4ce2', + 'actor_id' => 1, + 'handle' => 'Handle', + 'title' => 'Title', + 'description_markdown' => 'description', + 'description_html' => '

description

', + 'cover_id' => 1, + 'banner_id' => 2, + 'language_code' => 'en', + 'category_id' => 1, + 'parental_advisory' => null, + 'owner_name' => 'Owner', + 'owner_email' => 'Owner@gmail.com', + 'publisher' => '', + 'type' => 'episodic', + 'copyright' => '', + 'episode_description_footer_markdown' => null, + 'episode_description_footer_html' => null, + 'is_blocked' => 0, + 'is_completed' => 0, + 'is_locked' => 1, + 'imported_feed_url' => null, + 'new_feed_url' => null, + 'payment_pointer' => null, + 'location_name' => null, + 'location_geo' => null, + 'location_osm' => null, + 'custom_rss' => null, + 'is_published_on_hubs' => 0, + 'partner_id' => null, + 'partner_link_url' => null, + 'partner_image_url' => null, + 'created_by' => 1, + 'updated_by' => 1, + 'created_at' => '2022-06-13 8:00:00', + 'updated_at' => '2022-06-13 8:00:00', + ]; + } + + public function run(): void + { + $this->call(AppSeeder::class); + $this->call(TestSeeder::class); + $this->db->table('media') + ->insert(self::cover()); + $this->db->table('media') + ->insert(self::banner()); + $this->db->table('fediverse_actors') + ->insert(self::actor()); + $this->db->table('podcasts') + ->insert(self::podcast()); + } +} diff --git a/modules/Api/Rest/V1/Config/Api.php b/modules/Api/Rest/V1/Config/Api.php new file mode 100644 index 00000000..849732e0 --- /dev/null +++ b/modules/Api/Rest/V1/Config/Api.php @@ -0,0 +1,19 @@ +group( + config('Api') + ->gateway . 'podcasts', + [ + 'namespace' => 'Modules\Api\Rest\V1\Controllers', + 'filter' => 'rest-api', + ], + function ($routes): void { + $routes->get('/', 'PodcastController::list'); + $routes->get('(:num)', 'PodcastController::view/$1'); + $routes->get('(:any)', 'ExceptionController::notFound'); + } +); diff --git a/modules/Api/Rest/V1/Config/Services.php b/modules/Api/Rest/V1/Config/Services.php new file mode 100644 index 00000000..c4a3d72c --- /dev/null +++ b/modules/Api/Rest/V1/Config/Services.php @@ -0,0 +1,20 @@ +failNotFound('Podcast not found'); + } +} diff --git a/modules/Api/Rest/V1/Controllers/PodcastController.php b/modules/Api/Rest/V1/Controllers/PodcastController.php new file mode 100644 index 00000000..abfab638 --- /dev/null +++ b/modules/Api/Rest/V1/Controllers/PodcastController.php @@ -0,0 +1,42 @@ +initialize(); + } + + public function list(): Response + { + $data = (new PodcastModel())->findAll(); + array_map(function ($podcast): void { + $podcast->feed_url = $podcast->getFeedUrl(); + }, $data); + return $this->respond($data); + } + + public function view(int $id): Response + { + $data = (new PodcastModel())->getPodcastById($id); + if (! $data instanceof Podcast) { + return $this->failNotFound('Podcast not found'); + } + + $data->feed_url = $data->getFeedUrl(); + return $this->respond($data); + } +} diff --git a/modules/Api/Rest/V1/Core/Exceptions.php b/modules/Api/Rest/V1/Core/Exceptions.php new file mode 100644 index 00000000..4dae50e5 --- /dev/null +++ b/modules/Api/Rest/V1/Core/Exceptions.php @@ -0,0 +1,32 @@ + $statusCode, + 'error' => $statusCode, + 'messages' => [ + 'error' => 'Unexpected error', + ], + ]; + if (ENVIRONMENT === 'development') { + $data['messages'] = array_merge($data['messages'], [ + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTrace(), + ]); + } + + echo json_encode($data); + } +} diff --git a/modules/Api/Rest/V1/Filters/ApiFilter.php b/modules/Api/Rest/V1/Filters/ApiFilter.php new file mode 100644 index 00000000..efd23f35 --- /dev/null +++ b/modules/Api/Rest/V1/Filters/ApiFilter.php @@ -0,0 +1,25 @@ + - + - \ No newline at end of file + diff --git a/tests/modules/Api/Rest/V1/PodcastTest.php b/tests/modules/Api/Rest/V1/PodcastTest.php new file mode 100644 index 00000000..56a2f9c4 --- /dev/null +++ b/tests/modules/Api/Rest/V1/PodcastTest.php @@ -0,0 +1,109 @@ + + */ + private array $podcast = []; + + private string $podcastApiUrl; + + /** + * @param array $data + */ + public function __construct(?string $name = null, array $data = [], $dataName = '') + { + parent::__construct($name, $data, $dataName); + $this->podcast = FakeSinglePodcastApiSeeder::podcast(); + $this->podcast['created_at'] = []; + $this->podcast['updated_at'] = []; + $this->podcastApiUrl = config('Api') + ->gateway; + } + + public function testList(): void + { + $result = $this->call('get', $this->podcastApiUrl . 'podcasts'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONFragment([ + 0 => $this->podcast, + ]); + } + + public function testView(): void + { + $result = $this->call('get', $this->podcastApiUrl . 'podcasts/1'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONFragment($this->podcast); + } + + public function testViewNotFound(): void + { + $result = $this->call('get', $this->podcastApiUrl . 'podcasts/2'); + $result->assertStatus(404); + $result->assertJSONExact( + [ + 'status' => 404, + 'error' => 404, + 'messages' => [ + 'error' => 'Podcast not found', + ], + ] + ); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + } + + /* + * Refreshing database to fetch empty array of podcasts + */ + public function testListEmpty(): void + { + $this->regressDatabase(); + $this->migrateDatabase(); + $result = $this->call('get', $this->podcastApiUrl . 'podcasts'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONExact([]); + $this->seed($this->seed); + } +}