feat(api): add rest api with podcasts read endpoints

relates to #210
This commit is contained in:
Sebastian Janik 2022-06-22 10:48:58 +00:00 committed by Yassine Doghri
parent ea20206ee6
commit e64001d006
15 changed files with 780 additions and 5 deletions

View File

@ -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

View File

@ -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

View File

@ -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/',

View File

@ -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,
];
/**

View File

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
class FakeSinglePodcastApiSeeder extends Seeder
{
/**
* @return array<mixed>
*/
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<mixed>
*/
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<mixed>
*/
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' => '<p>description</p>',
'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<mixed>
*/
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' => '<p>description</p>',
'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());
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Config;
use CodeIgniter\Config\BaseConfig;
class Api extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* Rest API gateway
* --------------------------------------------------------------------------
* Defines a base route for all API pages
*/
public string $gateway = 'api/rest/v1/';
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Config;
$routes = service('routes');
$routes->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');
}
);

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Config;
use CodeIgniter\Config\BaseService;
use Modules\Api\Rest\V1\Core\Exceptions;
class Services extends BaseService
{
public static function restApiExceptions(bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('restApiExceptions');
}
return new Exceptions(config('Exceptions'), static::request(), static::response());
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Controllers;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\Response;
class ExceptionController extends Controller
{
use ResponseTrait;
public function notFound(): Response
{
return $this->failNotFound('Podcast not found');
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Controllers;
use App\Entities\Podcast;
use App\Models\PodcastModel;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\Response;
use Modules\Api\Rest\V1\Config\Services;
class PodcastController extends Controller
{
use ResponseTrait;
public function __construct()
{
Services::restApiExceptions()->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);
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Core;
use Throwable;
class Exceptions extends \CodeIgniter\Debug\Exceptions
{
protected function render(Throwable $exception, int $statusCode): void
{
header('Content-Type: application/json');
$data = [
'status' => $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);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Filters;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class ApiFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null): void
{
if (! getenv('REST_API_ENABLED')) {
throw PageNotFoundException::forPageNotFound();
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
// Do something here
}
}

View File

@ -0,0 +1,328 @@
{
"openapi": "3.0.0",
"info": {
"version": "1.0.0",
"title": "Castopod podcasts"
},
"paths": {
"/api/rest/v1/podcasts": {
"get": {
"summary": "List all podcasts",
"responses": {
"200": {
"description": "Object of podcasts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Podcasts"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/rest/v1/podcasts/{id}": {
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The id of the podcast to retrieve",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
}
}
],
"get": {
"summary": "Info for a specific podcast",
"responses": {
"200": {
"description": "Expected response to a valid request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Podcast"
}
}
}
},
"default": {
"description": "unexpected error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Podcast": {
"type": "object",
"required": [
"id",
"guid",
"actor_id",
"handle",
"title",
"description_markdown",
"description_html",
"cover_id",
"language_code",
"category_id",
"owner_name",
"owner_email",
"type",
"is_blocked",
"is_completed",
"is_locked",
"is_published_on_hubs",
"created_by",
"updated_by",
"created_at",
"updated_at",
"feed_url"
],
"properties": {
"id": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"guid": {
"type": "string",
"maxLength": 36
},
"actor_id": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"handle": {
"type": "string",
"maxLength": 32
},
"title": {
"type": "string",
"maxLength": 128
},
"description_markdown": {
"type": "string"
},
"description_html": {
"type": "string"
},
"cover_id": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"banner_id": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"language_code": {
"type": "string",
"maxLength": 2
},
"category_id": {
"type": "integer",
"format": "int64",
"minimum": 1
},
"parental_advisory": {
"type": "string",
"enum": ["clean", "explicit"]
},
"owner_name": {
"type": "string",
"maxLength": 128
},
"owner_email": {
"type": "string",
"maxLength": 255
},
"publisher": {
"type": "string",
"maxLength": 128
},
"type": {
"type": "string",
"enum": ["episodic", "serial"]
},
"copyright": {
"type": "string",
"maxLength": 128
},
"episode_description_footer_markdown": {
"type": "string"
},
"episode_description_footer_html": {
"type": "string"
},
"is_blocked": {
"type": "integer",
"format": "int32",
"enum": [0, 1],
"minLength": 1
},
"is_completed": {
"type": "integer",
"format": "int32",
"enum": [0, 1],
"minLength": 1
},
"is_locked": {
"type": "integer",
"format": "int32",
"enum": [0, 1],
"minLength": 1
},
"imported_feed_url": {
"type": "string",
"maxLength": 512
},
"new_feed_url": {
"type": "string",
"maxLength": 512
},
"payment_pointer": {
"type": "string",
"maxLength": 128
},
"location_name": {
"type": "string",
"maxLength": 128
},
"location_geo": {
"type": "string",
"maxLength": 32
},
"location_osm": {
"type": "string",
"maxLength": 12
},
"custom_rss": {
"type": "string"
},
"is_published_on_hubs": {
"type": "integer",
"format": "int32",
"enum": [0, 1],
"minLength": 1
},
"partner_id": {
"type": "string",
"maxLength": 32
},
"partner_link_url": {
"type": "string",
"maxLength": 512
},
"partner_image_url": {
"type": "string",
"maxLength": 512
},
"created_by": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"updated_by": {
"type": "integer",
"format": "int64",
"minimum": 1,
"maxLength": 10
},
"created_at": {
"type": "object",
"properties": {
"date": {
"type": "string",
"format": "date-time"
},
"timezone_type": {
"type": "integer",
"format": "int32"
},
"timezone": {
"type": "string"
}
}
},
"updated_at": {
"type": "object",
"properties": {
"date": {
"type": "string",
"format": "date-time"
},
"timezone_type": {
"type": "integer",
"format": "int32"
},
"timezone": {
"type": "string"
}
}
},
"feed_url": {
"type": "string"
}
}
},
"Podcasts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Podcast"
}
},
"Error": {
"type": "object",
"properties": {
"status": {
"type": "integer",
"format": "int32"
},
"error": {
"type": "integer",
"format": "int32"
},
"messages": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}

View File

@ -45,13 +45,12 @@
<!-- Directory containing the front controller (index.php) -->
<const name="PUBLICPATH" value="./public/"/>
<!-- Database configuration -->
<!-- Uncomment to provide your own database for testing
<env name="database.tests.hostname" value="localhost"/>
<env name="database.tests.hostname" value="mariadb"/>
<env name="database.tests.database" value="tests"/>
<env name="database.tests.username" value="tests_user"/>
<env name="database.tests.password" value=""/>
<env name="database.tests.password" value="password"/>
<env name="database.tests.DBDriver" value="MySQLi"/>
<env name="database.tests.DBPrefix" value="tests_"/>
-->
<env name="REST_API_ENABLED" value="1"/>
</php>
</phpunit>
</phpunit>

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace modules\Api\Rest\V1;
use App\Database\Seeds\FakeSinglePodcastApiSeeder;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
class PodcastTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
/**
* @var bool
*/
protected $migrate = true;
/**
* @var bool
*/
protected $migrateOnce = false;
/**
* @var string|null
*/
protected $namespace;
/**
* @var string
*/
protected $seed = 'FakeSinglePodcastApiSeeder';
/**
* @var string
*/
protected $basePath = 'app/Database';
/**
* @var array<mixed>
*/
private array $podcast = [];
private string $podcastApiUrl;
/**
* @param array<mixed> $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);
}
}