new: add /api openapi spec view with redoc, add faker to fixtures, validate api responses with openapi spec, add /api/v1/ prefix to api routes

pull/80/head
Luciano Righetti 2022-01-07 13:45:52 +01:00
parent f45727704f
commit a69608530c
18 changed files with 671 additions and 98 deletions

2
.gitignore vendored
View File

@ -8,4 +8,4 @@ webroot/theme/node_modules
.vscode
docker/run/
.phpunit.result.cache
config.json
config.json

View File

@ -19,7 +19,9 @@
"cakephp/bake": "^2.0.3",
"cakephp/cakephp-codesniffer": "~4.0.0",
"cakephp/debug_kit": "^4.0",
"fzaninotto/faker": "^1.9",
"josegonzalez/dotenv": "^3.2",
"league/openapi-psr7-validator": "^0.16.4",
"phpunit/phpunit": "^8.5",
"psy/psysh": "@stable"
},
@ -44,7 +46,6 @@
"scripts": {
"post-install-cmd": "App\\Console\\Installer::postInstall",
"post-create-project-cmd": "App\\Console\\Installer::postInstall",
"post-autoload-dump": "Cake\\Composer\\Installer\\PluginInstaller::postAutoloadDump",
"check": [
"@test",
"@cs-check"

View File

@ -92,14 +92,14 @@ $routes->prefix('Open', function (RouteBuilder $routes) {
$routes->fallbacks(DashedRoute::class);
});
/*
* If you need a different set of middleware or none at all,
* open new scope and define routes there.
*
* ```
* $routes->scope('/api', function (RouteBuilder $builder) {
* // No $builder->applyMiddleware() here.
* // Connect API actions here.
* });
* ```
*/
// API routes
$routes->scope('/api', function (RouteBuilder $routes) {
// $routes->applyMiddleware('ratelimit', 'auth.api');
$routes->scope('/v1', function (RouteBuilder $routes) {
// $routes->applyMiddleware('v1compat');
$routes->setExtensions(['json']);
// Generic API route
$routes->connect('/{controller}/{action}/*');
});
});

View File

@ -0,0 +1,19 @@
<?php
namespace App\Controller;
use App\Controller\AppController;
class ApiController extends AppController
{
/**
* Controller action for displaying built-in Redoc UI
*
* @return \Cake\Http\Response|null|void Renders view
*/
public function index()
{
$url = '/docs/openapi.yaml';
$this->set('url', $url);
}
}

View File

@ -193,6 +193,9 @@ class ACLComponent extends Component
'getBookmarks' => ['*'],
'saveBookmark' => ['*'],
'deleteBookmark' => ['*']
],
'Api' => [
'index' => ['*']
]
);

2
templates/Api/index.php Normal file
View File

@ -0,0 +1,2 @@
<redoc spec-url='<?php echo $url ?>'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>

View File

@ -11,24 +11,60 @@ class AuthKeysFixture extends TestFixture
{
public $connection = 'test';
public const ADMIN_API_KEY = '4cd687b314a3b9c4d83264e6195b9a3706ef4c2f';
public const ADMIN_API_KEY = 'd033e22ae348aeb5660fc2140aec35850c4da997';
public const SYNC_API_KEY = '6b387ced110858dcbcda36edb044dc18f91a0894';
public const ORG_ADMIN_API_KEY = '1c4685d281d478dbcebd494158024bc3539004d0';
public const USER_API_KEY = '12dea96fec20593566ab75692c9949596833adc9';
public function init(): void
{
$hasher = new DefaultPasswordHasher();
$faker = \Faker\Factory::create();
$this->records = [
[
'id' => 1,
'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5',
'uuid' => $faker->uuid(),
'authkey' => $hasher->hash(self::ADMIN_API_KEY),
'authkey_start' => '4cd6',
'authkey_end' => '4c2f',
'authkey_start' => substr(self::ADMIN_API_KEY, 0, 4),
'authkey_end' => substr(self::ADMIN_API_KEY, -4),
'expiration' => 0,
'user_id' => 1,
'user_id' => UsersFixture::USER_ADMIN_ID,
'comment' => '',
'created' => time(),
'modified' => time()
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'uuid' => $faker->uuid(),
'authkey' => $hasher->hash(self::SYNC_API_KEY),
'authkey_start' => substr(self::SYNC_API_KEY, 0, 4),
'authkey_end' => substr(self::SYNC_API_KEY, -4),
'expiration' => 0,
'user_id' => UsersFixture::USER_SYNC_ID,
'comment' => '',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'uuid' => $faker->uuid(),
'authkey' => $hasher->hash(self::ORG_ADMIN_API_KEY),
'authkey_start' => substr(self::ORG_ADMIN_API_KEY, 0, 4),
'authkey_end' => substr(self::ORG_ADMIN_API_KEY, -4),
'expiration' => 0,
'user_id' => UsersFixture::USER_ORG_ADMIN_ID,
'comment' => '',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'uuid' => $faker->uuid(),
'authkey' => $hasher->hash(self::USER_API_KEY),
'authkey_start' => substr(self::USER_API_KEY, 0, 4),
'authkey_end' => substr(self::USER_API_KEY, -4),
'expiration' => 0,
'user_id' => UsersFixture::USER_REGULAR_USER_ID,
'comment' => '',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
]
];
parent::init();

View File

@ -10,16 +10,64 @@ class IndividualsFixture extends TestFixture
{
public $connection = 'test';
public $records = [
[
'id' => 1,
'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e1',
'email' => 'admin@admin.test',
'first_name' => 'admin',
'last_name' => 'admin',
'position' => 'admin',
'created' => '2022-01-04 10:00:00',
'modified' => '2022-01-04 10:00:00'
]
];
// Admin individual
public const INDIVIDUAL_ADMIN_ID = 1;
// Sync individual
public const INDIVIDUAL_SYNC_ID = 2;
// Org Admin individual
public const INDIVIDUAL_ORG_ADMIN_ID = 3;
// Regular User individual
public const INDIVIDUAL_REGULAR_USER_ID = 4;
public function init(): void
{
$faker = \Faker\Factory::create();
$this->records = [
[
'id' => self::INDIVIDUAL_ADMIN_ID,
'uuid' => $faker->uuid(),
'email' => $faker->email(),
'first_name' => $faker->firstName,
'last_name' => $faker->lastName,
'position' => 'admin',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::INDIVIDUAL_SYNC_ID,
'uuid' => $faker->uuid(),
'email' => $faker->email(),
'first_name' => $faker->firstName,
'last_name' => $faker->lastName,
'position' => 'sync',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::INDIVIDUAL_ORG_ADMIN_ID,
'uuid' => $faker->uuid(),
'email' => $faker->email(),
'first_name' => $faker->firstName,
'last_name' => $faker->lastName,
'position' => 'org_admin',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::INDIVIDUAL_REGULAR_USER_ID,
'uuid' => $faker->uuid(),
'email' => $faker->email(),
'first_name' => $faker->firstName,
'last_name' => $faker->lastName,
'position' => 'user',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
]
];
parent::init();
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Test\Fixture;
use Cake\TestSuite\Fixture\TestFixture;
use Authentication\PasswordHasher\DefaultPasswordHasher;
class OrganisationsFixture extends TestFixture
{
public $connection = 'test';
// Organisation A
public const ORGANISATION_A_ID = 1;
// Organisation B
public const ORGANISATION_B_ID = 2;
public function init(): void
{
$faker = \Faker\Factory::create();
$this->records = [
[
'id' => self::ORGANISATION_A_ID,
'uuid' => $faker->uuid(),
'name' => 'Organisation A',
'url' => $faker->url,
'nationality' => $faker->countryCode,
'sector' => 'IT',
'type' => '',
'contacts' => '',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::ORGANISATION_B_ID,
'uuid' => $faker->uuid(),
'name' => 'Organisation B',
'url' => $faker->url,
'nationality' => $faker->countryCode,
'sector' => 'IT',
'type' => '',
'contacts' => '',
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
]
];
parent::init();
}
}

View File

@ -10,15 +10,53 @@ class RolesFixture extends TestFixture
{
public $connection = 'test';
public $records = [
[
'id' => 1,
'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e4',
'name' => 'admin',
'is_default' => true,
'perm_admin' => true,
'perm_sync' => true,
'perm_org_admin' => true
]
];
public const ROLE_ADMIN_ID = 1;
public const ROLE_SYNC_ID = 2;
public const ROLE_ORG_ADMIN_ID = 3;
public const ROLE_REGULAR_USER_ID = 4;
public function init(): void
{
$faker = \Faker\Factory::create();
$this->records = [
[
'id' => self::ROLE_ADMIN_ID,
'uuid' => $faker->uuid(),
'name' => 'admin',
'is_default' => false,
'perm_admin' => true,
'perm_sync' => false,
'perm_org_admin' => false
],
[
'id' => self::ROLE_SYNC_ID,
'uuid' => $faker->uuid(),
'name' => 'sync',
'is_default' => false,
'perm_admin' => false,
'perm_sync' => true,
'perm_org_admin' => false
],
[
'id' => self::ROLE_ORG_ADMIN_ID,
'uuid' => $faker->uuid(),
'name' => 'org_admin',
'is_default' => false,
'perm_admin' => false,
'perm_sync' => false,
'perm_org_admin' => true
],
[
'id' => self::ROLE_REGULAR_USER_ID,
'uuid' => $faker->uuid(),
'name' => 'user',
'is_default' => true,
'perm_admin' => false,
'perm_sync' => false,
'perm_org_admin' => false
]
];
parent::init();
}
}

View File

@ -11,26 +11,80 @@ class UsersFixture extends TestFixture
{
public $connection = 'test';
public const ADMIN_USER = 'admin';
public const ADMIN_PASSWORD = 'Password1234';
// Admin user
public const USER_ADMIN_ID = 1;
public const USER_ADMIN_USERNAME = 'admin';
public const USER_ADMIN_PASSWORD = 'AdminPassword';
// Sync user
public const USER_SYNC_ID = 2;
public const USER_SYNC_USERNAME = 'sync';
public const USER_SYNC_PASSWORD = 'SyncPassword';
// Org Admin user
public const USER_ORG_ADMIN_ID = 3;
public const USER_ORG_ADMIN_USERNAME = 'org_admin';
public const USER_ORG_ADMIN_PASSWORD = 'OrgAdminPassword';
// Regular User user
public const USER_REGULAR_USER_ID = 4;
public const USER_REGULAR_USER_USERNAME = 'user';
public const USER_REGULAR_USER_PASSWORD = 'UserPassword';
public function init(): void
{
$hasher = new DefaultPasswordHasher();
$faker = \Faker\Factory::create();
$this->records = [
[
'id' => 1,
'uuid' => '3ebfbe50-e7d2-406e-a092-f031e604b6e5',
'username' => self::ADMIN_USER,
'password' => $hasher->hash(self::ADMIN_PASSWORD),
'role_id' => 1,
'individual_id' => 1,
'id' => self::USER_ADMIN_ID,
'uuid' => $faker->uuid(),
'username' => self::USER_ADMIN_USERNAME,
'password' => $hasher->hash(self::USER_ADMIN_PASSWORD),
'role_id' => RolesFixture::ROLE_ADMIN_ID,
'individual_id' => IndividualsFixture::INDIVIDUAL_ADMIN_ID,
'disabled' => 0,
'organisation_id' => 1,
'created' => '2022-01-04 10:00:00',
'modified' => '2022-01-04 10:00:00'
'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID,
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::USER_SYNC_ID,
'uuid' => $faker->uuid(),
'username' => self::USER_SYNC_USERNAME,
'password' => $hasher->hash(self::USER_SYNC_PASSWORD),
'role_id' => RolesFixture::ROLE_SYNC_ID,
'individual_id' => IndividualsFixture::INDIVIDUAL_SYNC_ID,
'disabled' => 0,
'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID,
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::USER_ORG_ADMIN_ID,
'uuid' => $faker->uuid(),
'username' => self::USER_ORG_ADMIN_USERNAME,
'password' => $hasher->hash(self::USER_ORG_ADMIN_PASSWORD),
'role_id' => RolesFixture::ROLE_ORG_ADMIN_ID,
'individual_id' => IndividualsFixture::INDIVIDUAL_ORG_ADMIN_ID,
'disabled' => 0,
'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID,
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
],
[
'id' => self::USER_REGULAR_USER_ID,
'uuid' => $faker->uuid(),
'username' => self::USER_REGULAR_USER_USERNAME,
'password' => $hasher->hash(self::USER_REGULAR_USER_PASSWORD),
'role_id' => RolesFixture::ROLE_REGULAR_USER_ID,
'individual_id' => IndividualsFixture::INDIVIDUAL_REGULAR_USER_ID,
'disabled' => 0,
'organisation_id' => OrganisationsFixture::ORGANISATION_A_ID,
'created' => $faker->dateTime()->getTimestamp(),
'modified' => $faker->dateTime()->getTimestamp()
]
];
parent::init();

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Test\Helper;
use \League\OpenAPIValidation\PSR7\ValidatorBuilder;
use \League\OpenAPIValidation\PSR7\RequestValidator;
use \League\OpenAPIValidation\PSR7\ResponseValidator;
use \League\OpenAPIValidation\PSR7\OperationAddress;
trait ApiTestTrait
{
/** @var string */
protected $_authToken = '';
/** @var ValidatorBuilder */
private $validator;
/** @var RequestValidator */
private $requestValidator;
/** @var ResponseValidator */
private $responseValidator;
public function setAuthToken(string $authToken): void
{
$this->_authToken = $authToken;
// somehow this is not set automatically in test environment
$_SERVER['HTTP_AUTHORIZATION'] = $authToken;
$this->configRequest([
'headers' => [
'Accept' => 'application/json',
'Authorization' => $this->_authToken
]
]);
}
/**
* Parse the OpenAPI specification and create a validator
*
* @param string $specFile
* @return void
*/
public function initializeValidator(string $specFile): void
{
$this->validator = (new ValidatorBuilder)->fromYamlFile($specFile);
$this->requestValidator = $this->validator->getRequestValidator();
$this->responseValidator = $this->validator->getResponseValidator();
}
/**
* Validates the API request against the OpenAPI spec
*
* @param string $path The path to the API endpoint
* @param string $method The HTTP method used to call the endpoint
* @return void
*/
public function validateRequest(string $endpoint, string $method = 'get'): void
{
// TODO: find a workaround to create a PSR-7 request object for validation
throw NotImplementedException("Unfortunately cakephp does not save the PSR-7 request object in the test context");
}
/**
* Validates the API response against the OpenAPI spec
*
* @param string $path The path to the API endpoint
* @param string $method The HTTP method used to call the endpoint
* @return void
*/
public function validateResponse(string $endpoint, string $method = 'get'): void
{
$address = new OperationAddress($endpoint, $method);
$this->responseValidator->validate($address, $this->_response);
}
}

View File

@ -1,5 +1,14 @@
# Testing
Add a test database to your `config/app_local.php` config file and set `debug` mode to `true`.
1. Add a `cerebrate_test` database to the db:
```mysql
CREATE DATABASE cerebrate_test;
GRANT ALL PRIVILEGES ON cerebrate_test.* to cerebrate@localhost;
FLUSH PRIVILEGES;
QUIT;
```
2. Add a the test database to your `config/app_local.php` config file and set `debug` mode to `true`.
```php
'debug' => true,
'Datasources' => [

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Api\Users;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
use App\Test\Fixture\AuthKeysFixture;
use App\Test\Fixture\UsersFixture;
use App\Test\Helper\ApiTestTrait;
class UsersApiTest extends TestCase
{
use IntegrationTestTrait;
use ApiTestTrait;
protected const ENDPOINT = '/api/v1/users/view';
protected $fixtures = [
'app.Individuals',
'app.Roles',
'app.Users',
'app.AuthKeys'
];
public function setUp(): void
{
parent::setUp();
$this->initializeValidator(APP . '../webroot/docs/openapi.yaml');
}
public function testViewMe(): void
{
$this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY);
$this->get(self::ENDPOINT);
$this->assertResponseOk();
$this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME));
// TODO: $this->validateRequest()
$this->validateResponse(self::ENDPOINT);
}
public function testViewById(): void
{
$this->setAuthToken(AuthKeysFixture::ADMIN_API_KEY);
$url = sprintf('%s/%d', self::ENDPOINT, UsersFixture::USER_ADMIN_ID);
$this->get($url);
$this->assertResponseOk();
$this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::USER_ADMIN_USERNAME));
// TODO: $this->validateRequest()
$this->validateResponse($url);
}
}

View File

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Test\TestCase\Api;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;
use App\Test\Fixture\AuthKeysFixture;
use App\Test\Fixture\UsersFixture;
class UsersApiTest extends TestCase
{
use IntegrationTestTrait;
protected $fixtures = [
'app.Individuals',
'app.Roles',
'app.Users',
'app.AuthKeys'
];
public function testViewMe(): void
{
// ugly hack, $_SERVER['HTTP_AUTHORIZATION'] is not set automatically in test environment
$_SERVER['HTTP_AUTHORIZATION'] = AuthKeysFixture::ADMIN_API_KEY;
$this->configRequest([
'headers' => [
// this does not work: https://book.cakephp.org/4/en/development/testing.html#testing-stateless-authentication-and-apis
// 'Authorization' => AuthKeysFixture::ADMIN_API_KEY,
'Accept' => 'application/json'
]
]);
$this->get('/users/view');
$this->assertResponseOk();
$this->assertResponseContains(sprintf('"username": "%s"', UsersFixture::ADMIN_USER));
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Test\TestCase\Controller;
namespace App\Test\TestCase\Controller\Users;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;

View File

@ -66,4 +66,6 @@ if (!in_array('skip-migrations', $_SERVER['argv'])) {
['plugin' => 'Tags', 'connection' => 'test', 'skip' => ['*']],
['plugin' => 'ADmad/SocialAuth', 'connection' => 'test', 'skip' => ['*']]
]);
}else{
echo "[ * ] Skipping migrations ...\n";
}

215
webroot/docs/openapi.yaml Normal file
View File

@ -0,0 +1,215 @@
openapi: 3.0.0
info:
version: 1.3.0
title: Cerebrate Project API
description: |
TODO: markdown description
servers:
- url: https://cerebrate.local
tags:
- name: Users
description: "TODO: users resource descriptions"
paths:
/api/v1/users/view:
get:
summary: "Get information about the current user"
operationId: viewUserMe
tags:
- Users
responses:
"200":
$ref: "#/components/responses/ViewUserResponse"
"403":
$ref: "#/components/responses/UnauthorizedApiErrorResponse"
default:
$ref: "#/components/responses/ApiErrorResponse"
/api/v1/users/view/{userId}:
get:
summary: "Get information of a user by id"
operationId: viewUserById
tags:
- Users
parameters:
- $ref: "#/components/parameters/userId"
responses:
"200":
$ref: "#/components/responses/ViewUserResponse"
"403":
$ref: "#/components/responses/UnauthorizedApiErrorResponse"
default:
$ref: "#/components/responses/ApiErrorResponse"
components:
schemas:
# General
UUID:
type: string
format: uuid
maxLength: 36
example: "c99506a6-1255-4b71-afa5-7b8ba48c3b1b"
ID:
type: integer
format: int32
example: 1
DateTime:
type: string
format: datetime
example: "2022-01-05T11:19:26+00:00"
# Users
Username:
type: string
example: "admin"
User:
type: object
properties:
id:
$ref: "#/components/schemas/ID"
uuid:
$ref: "#/components/schemas/UUID"
username:
$ref: "#/components/schemas/Username"
role_id:
$ref: "#/components/schemas/ID"
individual_id:
$ref: "#/components/schemas/ID"
disabled:
type: boolean
created:
$ref: "#/components/schemas/DateTime"
modified:
$ref: "#/components/schemas/DateTime"
organisation_id:
$ref: "#/components/schemas/ID"
# Individuals
# Organisations
# Roles
RoleName:
type: string
maxLength: 255
example: "admin"
Role:
type: object
properties:
id:
$ref: "#/components/schemas/ID"
name:
$ref: "#/components/schemas/RoleName"
is_default:
type: boolean
perm_admin:
type: boolean
perm_sync:
type: boolean
perm_org_admin:
type: boolean
# Errors
ApiError:
type: object
required:
- name
- message
- url
properties:
name:
type: string
message:
type: string
url:
type: string
example: "/users"
UnauthorizedApiError:
type: object
required:
- name
- message
- url
properties:
name:
type: string
example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header."
message:
type: string
example: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header."
url:
type: string
example: "/users"
NotFoundApiError:
type: object
required:
- name
- message
- url
properties:
name:
type: string
example: "Invalid user"
message:
type: string
example: "Invalid user"
url:
type: string
example: "/users/1234"
parameters:
userId:
name: userId
in: path
description: "Numeric ID of the User"
required: true
schema:
$ref: "#/components/schemas/ID"
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: Authorization
description: |
The authorization is performed by using the following header in the HTTP requests:
Authorization: YOUR_API_KEY
# requestBodies:
responses:
# User
ViewUserResponse:
description: "User response"
content:
application/json:
schema:
$ref: "#/components/schemas/User"
# Errors
ApiErrorResponse:
description: "Unexpected API error"
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
UnauthorizedApiErrorResponse:
description: "Authentication failed. Please make sure you pass the API key of an API enabled user along in the Authorization header."
content:
application/json:
schema:
$ref: "#/components/schemas/UnauthorizedApiError"
security:
- ApiKeyAuth: []