API: Init with news endpoint (ro)

This commit is contained in:
Igor Scheller 2023-05-21 12:40:36 +02:00 committed by Michael Weimann
parent f826cee63c
commit 8adad075bf
12 changed files with 1025 additions and 148 deletions

View File

@ -44,12 +44,13 @@
"illuminate/database": "^10.23", "illuminate/database": "^10.23",
"illuminate/support": "^10.23", "illuminate/support": "^10.23",
"league/oauth2-client": "^2.7", "league/oauth2-client": "^2.7",
"league/openapi-psr7-validator": "^0.21",
"nikic/fast-route": "^1.3", "nikic/fast-route": "^1.3",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
"psr/container": "^2.0", "psr/container": "^2.0",
"psr/http-message": "^1.1",
"psr/http-server-middleware": "^1.0", "psr/http-server-middleware": "^1.0",
"psr/log": "^3.0", "psr/log": "^3.0",
"psr/http-message": "^1.1",
"rcrowe/twigbridge": "^0.14.0", "rcrowe/twigbridge": "^0.14.0",
"respect/validation": "^1.1", "respect/validation": "^1.1",
"symfony/http-foundation": "^6.3", "symfony/http-foundation": "^6.3",

841
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -108,7 +108,29 @@ $route->addGroup(
); );
// API // API
$route->get('/api[/{resource:.+}]', 'ApiController@index'); $route->addGroup(
'/api',
function (RouteCollector $route): void {
$route->get('/', 'ApiController@index');
$route->addGroup(
'/v0-beta',
function (RouteCollector $route): void {
$route->addRoute(['OPTIONS'], '[/{resource:.+}]', 'ApiController@options');
$route->get('/', 'ApiController@indexV0');
$route->get('/news', 'ApiController@news');
$route->addRoute(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'[/{resource:.+}]',
'ApiController@notImplemented'
);
}
);
$route->get('[/{resource:.+}]', 'ApiController@notImplemented');
}
);
// Feeds // Feeds
$route->get('/atom', 'FeedController@atom'); $route->get('/atom', 'FeedController@atom');

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Migrations;
use Engelsystem\Database\Migration\Migration;
class CreateApiPermissions extends Migration
{
/**
* Run the migration
*/
public function up(): void
{
$db = $this->schema->getConnection();
$db->table('privileges')->insert([
['name' => 'api', 'description' => 'Use the API'],
]);
$db->table('groups')->insert([
['id' => 40, 'name' => 'API'],
]);
$bureaucratGroup = 80;
$apiId = $db->table('privileges')->where('name', 'api')->first()->id;
$db->table('group_privileges')->insert([
['group_id' => $bureaucratGroup, 'privilege_id' => $apiId],
['group_id' => 40, 'privilege_id' => $apiId],
]);
}
/**
* Reverse the migration
*/
public function down(): void
{
$db = $this->schema->getConnection();
$db->table('privileges')
->where('name', 'api')
->delete();
$db->table('groups')
->where('id', 40)
->delete();
}
}

96
resources/api/openapi.yml Normal file
View File

@ -0,0 +1,96 @@
openapi: 3.0.3
info:
version: 0.0.1-beta
title: Engelsystem
description: |
This API description is as stable as a **beta** version might be.
It could burst into flames and morph into a monster at any second!
(But we try to keep it somewhat consistent, at least during events).
contact:
name: GitHub Issues
url: https://github.com/engelsystem/engelsystem/issues
license:
name: GPL 2.0
# identifier: GPL-2.0
servers:
- url: /api/v0-beta
description: This server
- url: http://localhost:5080/api/v0-beta
description: Your local dev instance
tags:
- name: news
description: News and Meeting announcements
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: API key from settings
responses:
UnauthorizedError:
description: Access token is missing or invalid
ForbiddenError:
description: The client is not allowed to acces
schemas:
News:
type: object
properties:
id:
type: integer
example: 42
title:
type: string
example: First helper introduction
text:
type: string
example: |
The first introduction meeting takes place at the **Foo Hall** at 13:37.
Please bring your own seating as it might take some time.
is_meeting:
type: boolean
example: true
description: Whether or not the news announces a meeting
is_pinned:
type: boolean
example: false
description: True if the news is pinned to the top
is_highlighted:
type: boolean
example: false
description: True if the news should be highlightet and shown on the dashboard
created_at:
type: string
example: 2023-05-13T13:37:42.000000Z
updated_at:
type: string
nullable: true
example: 2023-05-13T23:00:00.000000Z
security:
- bearerAuth: [ ]
paths:
/news:
get:
tags:
- news
summary: Get a list of all news
responses:
'200':
description: Ok
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/News'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'

6
resources/api/readme.md Normal file
View File

@ -0,0 +1,6 @@
# Engelsystem API documentation
Here you can find the OpenAPI files that describe the Engelsystem API.
Please be aware that the API is still in Beta and might change every second (but we try to keep it consistent during events ;))
## Links
* [OpenAPI Spec](https://swagger.io/specification/)

View File

@ -101,6 +101,7 @@ class Application extends Container
$this->instance('path', $appPath); $this->instance('path', $appPath);
$this->instance('path.config', $appPath . DIRECTORY_SEPARATOR . 'config'); $this->instance('path.config', $appPath . DIRECTORY_SEPARATOR . 'config');
$this->instance('path.resources', $appPath . DIRECTORY_SEPARATOR . 'resources'); $this->instance('path.resources', $appPath . DIRECTORY_SEPARATOR . 'resources');
$this->instance('path.resources.api', $this->get('path.resources') . DIRECTORY_SEPARATOR . 'api');
$this->instance('path.public', $appPath . DIRECTORY_SEPARATOR . 'public'); $this->instance('path.public', $appPath . DIRECTORY_SEPARATOR . 'public');
$this->instance('path.assets', $this->get('path.resources') . DIRECTORY_SEPARATOR . 'assets'); $this->instance('path.assets', $this->get('path.resources') . DIRECTORY_SEPARATOR . 'assets');
$this->instance('path.assets.public', $this->get('path.public') . DIRECTORY_SEPARATOR . 'assets'); $this->instance('path.assets.public', $this->get('path.public') . DIRECTORY_SEPARATOR . 'assets');

View File

@ -5,18 +5,66 @@ declare(strict_types=1);
namespace Engelsystem\Controllers; namespace Engelsystem\Controllers;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use Engelsystem\Models\News;
class ApiController extends BaseController class ApiController extends BaseController
{ {
public array $permissions = [
'api',
];
public function __construct(protected Response $response) public function __construct(protected Response $response)
{ {
$this->response = $this->response
->withHeader('content-type', 'application/json')
->withHeader('Access-Control-Allow-Origin', '*');
} }
public function index(): Response public function index(): Response
{
return $this->response
->withContent(json_encode([
'versions' => [
'0.0.1-beta' => '/v0-beta',
],
]));
}
public function indexV0(): Response
{
return $this->response
->withContent(json_encode([
'version' => '0.0.1-beta',
'description' => 'Beta API, might break any second.',
'paths' => [
'/news',
],
]));
}
public function options(): Response
{
return $this->response
->setStatusCode(204)
->withHeader('Allow', 'OPTIONS, HEAD, GET')
->withHeader('Access-Control-Allow-Headers', 'Authorization');
}
public function notImplemented(): Response
{ {
return $this->response return $this->response
->setStatusCode(501) ->setStatusCode(501)
->withHeader('content-type', 'application/json')
->withContent(json_encode(['error' => 'Not implemented'])); ->withContent(json_encode(['error' => 'Not implemented']));
} }
public function news(): Response
{
$news = News::query()
->orderByDesc('updated_at')
->orderByDesc('created_at')
->get(['id', 'title', 'text', 'is_meeting', 'is_pinned', 'is_highlighted', 'created_at', 'updated_at']);
return $this->response
->withContent(json_encode($news));
}
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Engelsystem\Middleware; namespace Engelsystem\Middleware;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
@ -13,14 +14,18 @@ use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface;
class SessionHandler implements MiddlewareInterface class SessionHandler implements MiddlewareInterface
{ {
public function __construct(protected SessionStorageInterface $session, protected array $paths = []) public function __construct(
{ protected SessionStorageInterface $session,
protected array $paths = [],
protected ?string $apiPrefix = null
) {
} }
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$requestPath = $request->getAttribute('route-request-path'); $requestPath = $request->getAttribute('route-request-path');
$isApi = in_array($requestPath, $this->paths); $isApi = in_array($requestPath, $this->paths)
|| ($this->apiPrefix && Str::startsWith($requestPath, $this->apiPrefix));
$request = $request->withAttribute('route-api', $isApi); $request = $request->withAttribute('route-api', $isApi);
$return = $handler->handle($request); $return = $handler->handle($request);

View File

@ -15,7 +15,6 @@ class SessionHandlerServiceProvider extends ServiceProvider
->needs('$paths') ->needs('$paths')
->give(function () { ->give(function () {
return [ return [
'/api',
'/atom', '/atom',
'/rss', '/rss',
'/health', '/health',
@ -25,5 +24,9 @@ class SessionHandlerServiceProvider extends ServiceProvider
'/stats', '/stats',
]; ];
}); });
$this->app
->when(SessionHandler::class)
->needs('$apiPrefix')
->give('/api');
} }
} }

View File

@ -52,6 +52,7 @@ class ApplicationTest extends TestCase
$this->assertTrue($app->has('path.config')); $this->assertTrue($app->has('path.config'));
$this->assertTrue($app->has('path.lang')); $this->assertTrue($app->has('path.lang'));
$this->assertTrue($app->has('path.resources')); $this->assertTrue($app->has('path.resources'));
$this->assertTrue($app->has('path.resources.api'));
$this->assertTrue($app->has('path.views')); $this->assertTrue($app->has('path.views'));
$this->assertTrue($app->has('path.storage')); $this->assertTrue($app->has('path.storage'));
$this->assertTrue($app->has('path.cache')); $this->assertTrue($app->has('path.cache'));

View File

@ -6,10 +6,15 @@ namespace Engelsystem\Test\Unit\Controllers;
use Engelsystem\Controllers\ApiController; use Engelsystem\Controllers\ApiController;
use Engelsystem\Http\Response; use Engelsystem\Http\Response;
use PHPUnit\Framework\TestCase; use Engelsystem\Models\News;
use League\OpenAPIValidation\PSR7\OperationAddress as OpenApiAddress;
use League\OpenAPIValidation\PSR7\ResponseValidator as OpenApiResponseValidator;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
class ApiControllerTest extends TestCase class ApiControllerTest extends ControllerTest
{ {
protected OpenApiResponseValidator $validator;
/** /**
* @covers \Engelsystem\Controllers\ApiController::__construct * @covers \Engelsystem\Controllers\ApiController::__construct
* @covers \Engelsystem\Controllers\ApiController::index * @covers \Engelsystem\Controllers\ApiController::index
@ -20,8 +25,89 @@ class ApiControllerTest extends TestCase
$response = $controller->index(); $response = $controller->index();
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertEquals(['*'], $response->getHeader('Access-Control-Allow-Origin'));
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('versions', $data);
}
/**
* @covers \Engelsystem\Controllers\ApiController::indexV0
*/
public function testIndexV0(): void
{
$controller = new ApiController(new Response());
$response = $controller->indexV0();
$this->assertEquals(200, $response->getStatusCode());
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertArrayHasKey('version', $data);
$this->assertArrayHasKey('paths', $data);
}
/**
* @covers \Engelsystem\Controllers\ApiController::options
*/
public function testOptions(): void
{
$controller = new ApiController(new Response());
$response = $controller->options();
$this->assertEquals(204, $response->getStatusCode());
$this->assertNotEmpty($response->getHeader('Allow'));
$this->assertNotEmpty($response->getHeader('Access-Control-Allow-Headers'));
}
/**
* @covers \Engelsystem\Controllers\ApiController::notImplemented
*/
public function testNotImplemented(): void
{
$controller = new ApiController(new Response());
$response = $controller->notImplemented();
$this->assertEquals(501, $response->getStatusCode()); $this->assertEquals(501, $response->getStatusCode());
$this->assertEquals(['application/json'], $response->getHeader('content-type')); $this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertJson($response->getContent()); $this->assertJson($response->getContent());
} }
/**
* @covers \Engelsystem\Controllers\ApiController::news
*/
public function testNews(): void
{
$this->initDatabase();
News::factory(3)->create();
$controller = new ApiController(new Response());
$response = $controller->news();
$operation = new OpenApiAddress('/news', 'get');
$this->validator->validate($operation, $response);
$this->assertEquals(['application/json'], $response->getHeader('content-type'));
$this->assertJson($response->getContent());
$data = json_decode($response->getContent(), true);
$this->assertCount(3, $data);
}
public function setUp(): void
{
parent::setUp();
$openApiDefinition = $this->app->get('path.resources.api') . '/openapi.yml';
$this->validator = (new ValidatorBuilder())
->fromYamlFile($openApiDefinition)
->getResponseValidator();
}
} }