This app implements an HTTP cached REST api with api key based security in Symfony. Be sure to checkout the published heroku app https://symfony-5-http-cached-rest-api.herokuapp.com/api/project-category to see it working in action!
The app utilizes and properly configures, the following bundles...
- FOSHttpCacheBundle
- FOSRestBundle
- Symfony SecurityBundle
- DoctrineBundle
- DoctrineMigrationsBundle
- JMSSerializerBundle
- SensioFrameworkExtraBundle
- HautelookAliceBundle
The provided Docker Compose configuration simplifies the implemention of the development environment, and will handle starting up the app. The docker environment consists of the following..
- PHP 8.0 FPM
- PostgreSQL 13.1
- Caddy 2.3.0
Caddy provides automatic HTTPS, and will expose the app at port 443 via the domain specified in an APP_URL
environment variable. Be sure to add this domain to your etc/hosts
file.
- Docker implementation includes PHP FPM, PostgreSQL, and Caddy server.
- Fully function REST API with
GET
,GET all
,POST
,PUT
, andDELETE
functionality. - API key based authentication for
POST
,PUT
, andDELETE
requests. - Automatic invalidation of url's upon
POST
,PUT
andDELETE
requests. - Sensitive information scrubbing at serialization (such as User passwords, and api keys).
- Automatic association resolving (Associated entities will be included in json).
- Sort, limit, and offset results of
GET all
requests. - Automatic HTTPS via Caddy Server.
- Enable and Disable HTTP Cache via
APP_CACHE
environment variable. - Automatic encoding of User passwords via Doctrine Listener, upon Persist, and Modification.
- Automatic conversion of Markdown to HTML via Doctrine Listener, upon Persist, and Modification.
- Fixtures created via
hautelook/alice-bundle
. - User, Category, Tag, Article, ProjectCategory, Project entities provided.
- Rename
.env.dist
to.env
. - Run
docker compose up
. - Optionally install fixtures by running
docker exec -t app_php_1 /bin/sh -c "php bin/console hautelook:fixtures:load"
A note on POST, PUT, and DELETE Requests:
In order to run any POST
, PUT
, or DELETE
requests, you'll need to send a valid API key in the X_AUTH_TOKEN
header. To aquire an API key, you'll need to create an inital user by installing the fixtures. Passwords, and API keys, are scrubbed from the JSON during serialization, so in order to actually retrieve the api key, you'll need to load up the database in an app like TablePlus.
The app includes a couple of custom event listeners that enable automatically resolving entity associations, and resource names during the request. Take a look at src/EventListener/AssociationNormalizingListener.php
and /src/EventListener/ResourceResolvingListener.php
to see how they work.
The entire api is implemented in one Controller across 5 methods. Take a look at the sole Controller /src/Controller/RestController.php
to see how it all works.
The following requests are accepted:
GET: /api/{resource}/{id}
GET All: /api/{resource}
POST: /api/{resource}
PUT: /api/{resource}/{id}
DELETE: /api/{resource}/{id}
Note: The GET all
request accepts three query parameters: order
, limit
, and offset
. These are passed directly to the Doctrine findBy()
method. Address the Doctrine documentation for further imformation.
Note: The POST
, PUT
, and DELETE
endpoints require that a valid api key is passed via the X_AUTH_TOKEN
header. Take a look at /src/Security/ApiTokenAuthenticator.php
and /config/packages/security.yaml
to see how I implemented that.
The FOSHttpCacheBundle simplifies the process of implemnting a gateway cache in Symfony. According to the bundle's docs, it's features include...
- Set path-based cache expiration headers via your app configuration;
- Set up an invalidation scheme without writing PHP code;
- Tag your responses and invalidate cache based on tags;
- Send invalidation requests with minimal impact on performance;
- Differentiate caches based on user type (e.g. roles);
- Easily implement your own HTTP cache client.
I've chosen to implement the Symfony based HTTP cache. That being said, Varnish and Nginx implementations are included by the bundle. See the documentation for more information.
A note on expiration:
Typically when configuring expiration headers, we use the max-age
or s-max-age
headers. The max-age
header is honored by the browser when it caches requests. The s-max-age
is used by reverse proxies such as Nginx, as well as cdn's like Cloudflare.
However this leads to a problem when using Symfony as a gateway cache. If I configure the Symfony gateway cache to cache an endpoint for 86400 seconds, I only want the Symfony cache to do so and not another cache implementation that may recieve the request before Symfony does. Such as an Nginx reverse proxy.
The bundle authors have thankfully addressed this issue by implementing a reverse_proxy_ttl
header that only the Symfony gateway cache honors.
You can of course still set, max-age, or s-max-age headers. In fact a common strategy recommended is to set the max-age
header to a shorter length such as 500, and then the reverse_proxy_ttl
to a longer length such as 86400. The max-age
header allows the users own browser to cache the request during the short term and not any other cache implementations. And then the reverse_proxy_ttl
directs the Symfony caches ttl which will be served to everyone. Remember we can't directly control the users browser cache. We can however, control and invalidate the Symfony gateway cache at will.
Issues I Addressed:
A major issue that I encountered with the bundle was disabling the http cache during development. Even when disabling the http cache the 'normal' way, Symfony was still picking up the fos_http_cache.yaml
bundle congiguration and somehow implementing certain cache features.
Here is the standard way the bundle recommends implementing the http cache:
// /public/index.php
// ...
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel = $kernel->getHttpCache();
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
// ...
// /src/Kernel.php
// ...
class Kernel extends BaseKernel implements HttpCacheProvider {
use MicroKernelTrait;
use HttpCacheAware;
public function __construct(string $environment, bool $debug) {
parent::__construct($environment, $debug);
$this->setHttpCache(new CacheKernel($this, null, ['debug' => $debug]));
}
To fix this, I decided to..
- implement an
APP_CACHE
environment variable. When set to false, the http cache disables. - Remove
fos_http_cache.yaml
from/config/packages/fos_http_cache.yaml
and move it to/config/http_cache/fos_http_cache.yaml
. - In
/src/Kernel.php
and/public/index.php
I made the following additions..
// /public/index.php
// ...
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG'], (bool) $_SERVER['APP_CACHE']);
if ($_SERVER['APP_CACHE']) {
$kernel = $kernel->getHttpCache();
}
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
// ...
// /src/Kernel.php
class Kernel extends BaseKernel implements HttpCacheProvider {
use MicroKernelTrait;
use HttpCacheAware;
// This
private $enableCache = false;
public function __construct(string $environment, bool $debug, /* This -> */ bool $cache = false) {
parent::__construct($environment, $debug);
// This
if ($cache) {
$this->enableCache = true;
$this->setHttpCache(new CacheKernel($this, null, ['debug' => $debug]));
}
}
protected function configureContainer(ContainerConfigurator $container): void {
$container->import('../config/{packages}/*.yaml');
$container->import('../config/{packages}/' . $this->environment . '/*.yaml');
// And this
if ($this->enableCache) {
$container->import('../config/http_cache/fos_http_cache.yaml');
}
if (is_file(\dirname(__DIR__) . '/config/services.yaml')) {
$container->import('../config/{services}.yaml');
$container->import('../config/{services}_' . $this->environment . '.yaml');
} elseif (is_file($path = \dirname(__DIR__) . '/config/services.php')) {
require $path($container->withPath($path), $this);
}
}
// ...
And yeah I can't think of much more to cover just now. If you run into any issues, feel free to leave an issue, and ill adress them. And yeah. I hope this example is educational, and can help as many as possible.