├── .env
├── .gitignore
├── README.md
├── YOUTUBE_SETUP.md
├── bin
└── doctrine
├── bootstrap
└── bootstrap.php
├── composer.json
├── composer.lock
├── config
├── di.php
└── settings.php
├── doc
└── flights-api.png
├── docker-compose.yaml
├── jwks.json
├── jwt-create.php
├── key-gen.php
├── middleware
└── registration.php
├── nginx
├── header-versioning.conf
└── media-type-versioning.conf
├── oas
└── flights.yaml
├── phpstan.neon
├── public
├── html
│ ├── api-changelog.html
│ └── migration-guide.html
└── index.php
├── routes
└── api.php
├── sql
├── add-ownerId-to-reservations.sql
├── flights-query-params.sql
├── flights.sql
├── index-cancelled-at.sql
├── passengers.sql
├── reservations.sql
└── seed-tables.sql
└── src
├── Controller
├── ApiController.php
├── FlightsController.php
├── PassengersController.php
└── ReservationsController.php
├── Entity
├── Flight.php
├── Passenger.php
├── Reservation.php
├── ResourceInterface.php
├── ResourceValidator.php
└── User.php
├── Http
├── Error
│ ├── Exception
│ │ ├── ExtensibleExceptionInterface.php
│ │ └── ValidationException.php
│ ├── HttpErrorHandler.php
│ └── ProblemDetail.php
└── Middleware
│ ├── Cache
│ ├── ContentCacheMiddleware.php
│ └── HttpCacheMiddleware.php
│ ├── ContentNegotiation
│ ├── ContentNegotiationInterface.php
│ ├── ContentType.php
│ ├── ContentTypeMiddleware.php
│ └── ContentTypeNegotiator.php
│ ├── Deprecations
│ └── SunsetHeaderMiddleware.php
│ ├── MiddlewareRegistrar.php
│ ├── Security
│ ├── JwtAuthenticationMiddleware.php
│ └── PermissionsMiddleware.php
│ └── Utility
│ └── MaintenanceModeMiddleware.php
├── Pagination
├── PaginationMetadata.php
└── PaginationMetadataFactory.php
├── Repository
├── FlightRepository.php
├── QueryUtils
│ └── Sort.php
└── ReservationRepository.php
├── Security
├── AccessControlManager.php
└── TokenAuthenticator.php
└── Serializer
└── Serializer.php
/.env:
--------------------------------------------------------------------------------
1 | # .env
2 | DSN=pdo-mysql://user:secret@db:3306/flights-api?charset=utf8mb4
3 | MAINTENANCE_MODE=false
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | .idea
3 | notes.txt
4 | /var
5 | private.pem
6 | public.pem
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## PHP API Pro
2 | [GaryClarkeTech](https://garyclarke.tech)
3 | ---
4 |
5 | This is the repository which accompanies [PHP API Pro](https://www.garyclarke.tech/p/php-api-pro). A comprehensive step-by-step video course guiding you through the process of creating awesome APIs in PHP.
6 |
7 | ### Setup
8 |
9 | In the course, I use [Docker](https://www.garyclarke.tech/p/learn-docker-and-php) to create my development environment and created custom images especially for this course.
10 | It is a very simple setup consisting of PHP, Nginx, and MariaDB (MySQL) containers. Spin it up with these two commands.
11 |
12 | ```shell
13 | docker compose up -d
14 | docker compose exec app composer install
15 | ```
16 | This will give you the exact same setup as me with the exact same versions of all the dependencies. This will make it much easier for me to help you, should you encounter problems.
17 |
18 | ### Branches
19 | Each individual lesson has a corresponding branch in this repo. There is a link to the branch in the lesson text e.g.
20 |
21 | Branch: [https://github.com/GaryClarke/php-api-pro/tree/3-phpstan-composer-script](https://github.com/GaryClarke/php-api-pro/tree/3-phpstan-composer-script)
22 |
23 | Happy Coding!
24 |
25 |
--------------------------------------------------------------------------------
/YOUTUBE_SETUP.md:
--------------------------------------------------------------------------------
1 | ## YouTube Setup
2 |
3 | These instructions are for students who are following the YouTube edit of the course.
4 |
5 | ### Branches
6 |
7 | The YouTube edit joins the course partway through. The good news is that exact start point has its own branch named `youtube-start`. Checkout that branch first..
8 | ```shell
9 | git checkout youtube-start
10 | ```
11 |
12 | ### Docker
13 |
14 | I created Docker images especially for the course so that you'd have everything you need without having to install software yourself..except for Docker of course.
15 |
16 | Get up and running with this..
17 | ```shell
18 | docker compose up -d
19 | ```
20 |
21 | Then install all the composer dependencies. In order to ensure you have all the same versions as me, run composer __install__, not update.
22 | ```shell
23 | docker compose exec app composer install
24 | ```
25 |
26 | ---
27 |
28 | ### Connect to the database
29 |
30 | Credentials for connecting to the DB can be found in the .env file...
31 | ```.dotenv
32 | DSN=pdo-mysql://user:secret@db:3306/flights-api?charset=utf8mb4
33 | ```
34 |
35 | ..and also in the docker-compose.yaml file
36 | ```yaml
37 | db:
38 | # ...
39 | environment:
40 | MARIADB_ROOT_PASSWORD: secret
41 | MARIADB_DATABASE: flights-api
42 | MARIADB_USER: user
43 | MARIADB_PASSWORD: secret
44 | ports:
45 | - "3306:3306"
46 | ```
47 |
48 | You can use those credentials to connect your DB admin tool of choice. I use [TablePlus](https://tableplus.com/) in the video.
49 |
50 | 
51 |
52 | You now have all the configuration you need to follow along.
53 |
54 | ---
55 |
56 | ### Enroll in the Full Course
57 | The YouTube edit is only a sample section of the full course. You can enroll in the full course here:
58 | [https://www.garyclarke.tech/p/php-api-pro](https://www.garyclarke.tech/p/php-api-pro)
59 |
60 | The full course includes:
61 | * API Fundamentals
62 | * REST Operations
63 | * API Design and Documentation
64 | * Error Handling
65 | * Response Content
66 | * Performance and Optimization
67 | * API Security
68 | * API Versioning
69 | * API Testing
70 | * Consuming APIs
71 | * Other API Types
72 |
73 | Get a 20% discount using this coupon code: GCTREPO20
74 |
75 | Happy Coding!
76 |
77 |
--------------------------------------------------------------------------------
/bin/doctrine:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | get(EntityManagerInterface::class);
20 |
21 | ConsoleRunner::run(new SingleManagerProvider($entityManager));
--------------------------------------------------------------------------------
/bootstrap/bootstrap.php:
--------------------------------------------------------------------------------
1 | get('settings')['app'];
6 |
7 | // Define routes
8 | include $appSettings['api_routes'];
9 |
10 | // Define middleware
11 | include $appSettings['middleware_registration'];
12 |
13 |
14 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require": {
3 | "ext-openssl": "*",
4 | "slim/slim": "4.*",
5 | "slim/psr7": "^1.6",
6 | "ext-json": "*",
7 | "league/container": "^4.2",
8 | "monolog/monolog": "^3.5",
9 | "middlewares/trailing-slash": "^2.0",
10 | "doctrine/orm": "^2.17",
11 | "symfony/cache": "^7.0",
12 | "symfony/dotenv": "^7.0",
13 | "symfony/serializer": "^7.0",
14 | "symfony/property-access": "^7.0",
15 | "symfony/validator": "^7.0",
16 | "firebase/php-jwt": "^6.10",
17 | "ramsey/uuid": "^4.7"
18 | },
19 | "require-dev": {
20 | "symfony/var-dumper": "^7.0",
21 | "phpunit/phpunit": "^10.5",
22 | "phpstan/phpstan": "^1.10",
23 | "phpstan/phpstan-doctrine": "^1.3",
24 | "phpstan/extension-installer": "^1.3"
25 | },
26 | "autoload": {
27 | "psr-4": {
28 | "App\\": "src"
29 | }
30 | },
31 | "autoload-dev": {
32 | "psr-4": {
33 | "App\\Tests\\": "tests"
34 | }
35 | },
36 | "config": {
37 | "allow-plugins": {
38 | "phpstan/extension-installer": true
39 | }
40 | },
41 | "scripts": {
42 | "run-phpstan": "vendor/bin/phpstan analyse -l 9 src config"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/config/di.php:
--------------------------------------------------------------------------------
1 | delegate(new ReflectionContainer(true));
16 |
17 | # Settings
18 | $settings = require_once __DIR__ . '/settings.php';
19 | $container->add('settings', new ArrayArgument($settings));
20 | $container->add('maintenance_mode', function() use ($settings) {
21 | return $settings['app']['maintenance_mode'] === 'true';
22 | });
23 |
24 | # Services
25 | $container->addShared(EntityManagerInterface::class, function() use ($settings) : EntityManagerInterface {
26 | $config = ORMSetup::createAttributeMetadataConfiguration(
27 | paths: $settings['doctrine']['metadata_dirs'],
28 | isDevMode: $settings['doctrine']['dev_mode']
29 | );
30 |
31 | $dsn = $settings['doctrine']['connection']['dsn'];
32 | $dsnParser = new DsnParser();
33 | $connectionParams = $dsnParser->parse($dsn);
34 |
35 | $connection = DriverManager::getConnection(
36 | params: $connectionParams,
37 | config: $config
38 | );
39 | return new EntityManager($connection, $config);
40 | });
41 |
42 | $container->addShared(\App\Repository\ReservationRepository::class, function () use ($container) {
43 | $entityManager = $container->get(EntityManagerInterface::class);
44 |
45 | return $entityManager->getRepository(\App\Entity\Reservation::class);
46 | });
47 |
48 | $container->addShared(\App\Repository\FlightRepository::class, function () use ($container) {
49 | $entityManager = $container->get(EntityManagerInterface::class);
50 | return $entityManager->getRepository(\App\Entity\Flight::class);
51 | });
52 |
53 | $container->add(\Symfony\Component\Serializer\SerializerInterface::class, function() {
54 | $encoders = [
55 | new \Symfony\Component\Serializer\Encoder\XmlEncoder(),
56 | new \Symfony\Component\Serializer\Encoder\JsonEncoder()
57 | ];
58 |
59 | $classMetadataFactory = new \Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory(
60 | new \Symfony\Component\Serializer\Mapping\Loader\AttributeLoader()
61 | );
62 |
63 | $metadataAwareNameConverter = new \Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter($classMetadataFactory);
64 |
65 | $normalizers = [
66 | new \Symfony\Component\Serializer\Normalizer\DateTimeNormalizer(),
67 | new \Symfony\Component\Serializer\Normalizer\ObjectNormalizer(
68 | $classMetadataFactory,
69 | $metadataAwareNameConverter,
70 | ),
71 | ];
72 |
73 | return new \Symfony\Component\Serializer\Serializer($normalizers, $encoders);
74 | });
75 |
76 | $container->addShared(\App\Serializer\Serializer::class)
77 | ->addArguments([\Symfony\Component\Serializer\SerializerInterface::class]);
78 |
79 | $container->add(\Symfony\Component\Validator\Validator\ValidatorInterface::class, function () {
80 | return \Symfony\Component\Validator\Validation::createValidatorBuilder()
81 | ->enableAttributeMapping()
82 | ->getValidator();
83 | });
84 |
85 | $container->add(\Psr\Log\LoggerInterface::class, function() use ($settings) {
86 | $logger = new \Monolog\Logger($settings['log']['name']);
87 | $streamHandler = new \Monolog\Handler\StreamHandler(
88 | $settings['log']['file'],
89 | \Monolog\Level::fromName($settings['log']['level'])
90 | );
91 | $logger->pushHandler($streamHandler);
92 | return $logger;
93 | });
94 |
95 | $container->add(\Symfony\Contracts\Cache\CacheInterface::class, \Symfony\Component\Cache\Adapter\FilesystemAdapter::class);
96 |
97 | return $container;
98 |
99 |
--------------------------------------------------------------------------------
/config/settings.php:
--------------------------------------------------------------------------------
1 | load(dirname(__DIR__) . '/.env');
9 | define('APP_ROOT', dirname(__DIR__));
10 |
11 | return [
12 | 'app' => [
13 | // Returns a detailed HTML page with error details and
14 | // a stack trace. Should be disabled in production.
15 | 'display_error_details' => true,
16 |
17 | // Whether to display errors on the internal PHP log or not.
18 | 'log_errors' => true,
19 |
20 | // If true, display full errors with message and stack trace on the PHP log.
21 | // If false, display only "Slim Application Error" on the PHP log.
22 | // Doesn't do anything when 'logErrors' is false.
23 | 'log_error_details' => true,
24 |
25 | 'project_dir' => APP_ROOT,
26 |
27 | 'routes_dir' => APP_ROOT . '/routes',
28 |
29 | 'api_routes' => APP_ROOT . '/routes/api.php',
30 |
31 | 'middleware_registration' => APP_ROOT . '/middleware/registration.php',
32 |
33 | 'maintenance_mode' => $_ENV['MAINTENANCE_MODE'] ?? false,
34 | ],
35 | 'doctrine' => [
36 | // Enables or disables Doctrine metadata caching
37 | // for either performance or convenience during development.
38 | 'dev_mode' => true,
39 |
40 | // Path where Doctrine will cache the processed metadata
41 | // when 'dev_mode' is false.
42 | 'cache_dir' => APP_ROOT . '/var/doctrine',
43 |
44 | // List of paths where Doctrine will search for metadata.
45 | // Metadata can be either YML/XML files or PHP classes annotated
46 | // with comments or PHP8 attributes.
47 | 'metadata_dirs' => [APP_ROOT . '/src/Entity'],
48 |
49 | // The parameters Doctrine needs to connect to your database.
50 | // These parameters depend on the driver (for instance the 'pdo_sqlite' driver
51 | // needs a 'path' parameter and doesn't use most of the ones shown in this example).
52 | // Refer to the Doctrine documentation to see the full list
53 | // of valid parameters: https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/configuration.html
54 | 'connection' => [
55 | 'dsn' => $_ENV['DSN']
56 | ]
57 | ],
58 | 'log' => [
59 | 'file' => APP_ROOT . '/var/app.log',
60 | 'level' => $_ENV['LOG_LEVEL'] ?? 'debug',
61 | 'name' => 'app',
62 | ]
63 | ];
64 |
--------------------------------------------------------------------------------
/doc/flights-api.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GaryClarke/php-api-pro/9d3831074cb96435da20972402b75f9d939aa076/doc/flights-api.png
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | web:
5 | image: garyclarke/nginx24-multi
6 | ports:
7 | - "8080:80" # Maps port 8080 on the host to port 80 in the container
8 | - "8443:443" # Maps port 8443 on the host to port 443 in the container (if you're using HTTPS)
9 | volumes:
10 | - ./public:/var/www/html/public
11 | app:
12 | image: garyclarke/php83-multi
13 | environment:
14 | MARIADB_HOST: db
15 | volumes:
16 | - .:/var/www/html
17 | extra_hosts:
18 | - host.docker.internal:host-gateway
19 | db:
20 | image: mariadb:11.2
21 | command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
22 | restart: on-failure
23 | volumes:
24 | - flightsdata:/var/lib/mysql
25 | environment:
26 | MARIADB_ROOT_PASSWORD: secret
27 | MARIADB_DATABASE: flights-api
28 | MARIADB_USER: user
29 | MARIADB_PASSWORD: secret
30 | ports:
31 | - "3306:3306"
32 | volumes:
33 | flightsdata:
34 |
--------------------------------------------------------------------------------
/jwks.json:
--------------------------------------------------------------------------------
1 | {
2 | "keys": [
3 | {
4 | "kty": "RSA",
5 | "use": "sig",
6 | "kid": "1234567890abcdef",
7 | "n": "oZpSv3BcHeDzDsgNqphCaf1fG2xOB5kOi4Z5KA7zap9MDG4qDxsPa_5KsY8a...",
8 | "e": "AQAB",
9 | "alg": "RS256"
10 | },
11 | {
12 | "kty": "RSA",
13 | "use": "sig",
14 | "kid": "abcdef1234567890",
15 | "n": "yEalRQcBQ180Wabt3Lgxo4VPcexj3b7ZVQcbKd5z7GtVCH8JZLOKMlkjh95N...",
16 | "e": "AQAB",
17 | "alg": "RS256"
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/jwt-create.php:
--------------------------------------------------------------------------------
1 | "https://yourdomain.com", // The issuer of the token
10 | "sub" => "user12345", // The subject of the token (e.g., user ID)
11 | "aud" => "https://jet-fu.com", // The audience of the token
12 | "exp" => $expirationTime, // Token expiration time
13 | "iat" => $issuedAt, // Issuance time
14 | "jti" => bin2hex(random_bytes(16)) // JWT ID to prevent replay attacks
15 | ];
16 |
17 | // Optionally, you might want to include additional claims:
18 | $payload['role'] = 'partner'; // Custom claim for user role
19 | $payload['email'] = 'user@example.com';// Custom claim for user email
20 |
21 | $privateKey = file_get_contents('private.pem');
22 |
23 | $uuid = \Ramsey\Uuid\Uuid::uuid4();
24 | $kid = $uuid->toString();
25 |
26 | // Generate the JWT
27 |
28 |
29 | $jwt = \Firebase\JWT\JWT::encode($payload, $privateKey, 'RS256', $kid);
30 |
31 | dd($jwt);
--------------------------------------------------------------------------------
/key-gen.php:
--------------------------------------------------------------------------------
1 | "sha256",
9 | "private_key_bits" => 2048,
10 | "private_key_type" => OPENSSL_KEYTYPE_RSA,
11 | ];
12 |
13 | // Create the private key resource
14 | $privateKeyResource = openssl_pkey_new($config);
15 |
16 | // Extract the private key from the resource
17 | openssl_pkey_export($privateKeyResource, $privateKey);
18 |
19 | // Get the public key that corresponds to the private key
20 | $publicKeyDetails = openssl_pkey_get_details($privateKeyResource);
21 | $publicKey = $publicKeyDetails["key"];
22 |
23 | // Save the private key and public key to disk
24 | file_put_contents('private.pem', $privateKey);
25 | file_put_contents('public.pem', $publicKey);
26 |
27 | echo "Keys generated successfully:\n";
28 | echo "Private Key: private.pem\n";
29 | echo "Public Key: public.pem\n";
30 |
31 |
--------------------------------------------------------------------------------
/middleware/registration.php:
--------------------------------------------------------------------------------
1 | register();
10 |
--------------------------------------------------------------------------------
/nginx/header-versioning.conf:
--------------------------------------------------------------------------------
1 | # Define a block for managing HTTP-specific configurations
2 | http {
3 | # Define an upstream group for API version 1. This groups servers that handle requests for version 1 of the API.
4 | upstream v1_backend {
5 | server 192.168.1.101; # IP address of the server that handles version 1 of the API
6 | }
7 |
8 | # Define an upstream group for API version 2. This groups servers that handle requests for version 2 of the API.
9 | upstream v2_backend {
10 | server 192.168.1.102; # IP address of the server that handles version 2 of the API
11 | }
12 |
13 | # Define a server block that listens on port 80 (default HTTP port).
14 | server {
15 | listen 80;
16 |
17 | # Define a location block that matches all requests.
18 | location / {
19 | # Set the Host header of the proxied request to match the host header of the original request.
20 | proxy_set_header Host $host;
21 |
22 | # Dynamically define the upstream group to proxy requests to. Initial default is v1_backend.
23 | set $upstream v1_backend; # Set the default upstream to version 1 backend
24 |
25 | # Check the incoming request for a specific custom header ('X-API-Version').
26 | # If it equals '2.0', change the upstream group to the one handling API version 2.
27 | if ($http_x_api_version = '2.0') {
28 | set $upstream v2_backend; # If header matches '2.0', set the upstream to version 2 backend
29 | }
30 |
31 | # Proxy the request to the upstream server group defined by the $upstream variable.
32 | # This could be either v1_backend or v2_backend depending on the header.
33 | proxy_pass http://$upstream;
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/nginx/media-type-versioning.conf:
--------------------------------------------------------------------------------
1 | # Nginx HTTP server configuration block
2 | # nginx/media-type-versioning.conf
3 | http {
4 | # Maps the 'Accept' HTTP header to a variable named $version based on its contents.
5 | # This mapping is used to determine which version of the API to route to.
6 | map $http_accept $version {
7 | default "v1"; # Set default version to "v1" if no matching pattern is found.
8 | "~*vnd.example.v2" "v2"; # If the Accept header contains "vnd.example.v2", set $version to "v2".
9 | }
10 |
11 | # Defines an upstream server group for API version 1.
12 | # Requests routed to this group will be handled by the server specified.
13 | upstream api-v1 {
14 | server api-v1.example.com; # Domain or IP address of the server handling version 1 of the API.
15 | }
16 |
17 | # Defines an upstream server group for API version 2.
18 | # Requests routed to this group will be handled by the server specified.
19 | upstream api-v2 {
20 | server api-v2.example.com; # Domain or IP address of the server handling version 2 of the API.
21 | }
22 |
23 | # Server block for handling incoming HTTP requests on port 80.
24 | server {
25 | listen 80; # Listens on port 80 for incoming HTTP requests.
26 |
27 | # Location block that matches all requests.
28 | # This block defines how requests are handled at the root URL path "/".
29 | location / {
30 | # Proxies the request to the upstream server group based on the $version variable.
31 | # This variable is set by the map directive based on the Accept header.
32 | proxy_pass http://api-$version;
33 |
34 | # Sets the Host header of the proxied request to match the original Host header received.
35 | proxy_set_header Host $host;
36 |
37 | # Adds the client's IP address to the 'X-Forwarded-For' header.
38 | # This is used for logging and analytics in the proxied server.
39 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/oas/flights.yaml:
--------------------------------------------------------------------------------
1 | openapi: 3.0.3
2 |
3 | info:
4 | title: Flights API - OpenAPI 3.0
5 | description: |-
6 | This is a sample Flights Server based on the OpenAPI 3.0 specification which accompanies the [PHP API Pro](https://www.garyclarke.tech/p/php-api-pro) course at [garyclarke.tech](https://www.garyclarke.tech).
7 |
8 | The course shows you how to build a Flights API in PHP using a **Design-first** approach.
9 | version: 1.0.0
10 |
11 | servers:
12 | - url: https://jet-fu.com
13 |
14 | tags:
15 | - name: flight
16 | description: Flight operations
17 | - name: reservation
18 | description: Reservation operations
19 | - name: passenger
20 | description: Passenger operations
21 |
22 | paths:
23 | /flights:
24 |
25 | #--------------- getFlights ---------------
26 | get:
27 | tags:
28 | - flight
29 | summary: Find all flights
30 | description: Get a paginated list of flights
31 | operationId: getFlights
32 | parameters:
33 | - in: query
34 | name: number
35 | schema:
36 | type: string
37 | description: Filter by flight number
38 | - in: query
39 | name: origin
40 | schema:
41 | type: string
42 | description: Filter by origin location
43 | - in: query
44 | name: destination
45 | schema:
46 | type: string
47 | description: Filter by destination location
48 | - in: query
49 | name: departureTime
50 | schema:
51 | type: string
52 | description: Filter by departure date (use YYYY-MM-DD)
53 | - in: query
54 | name: sort
55 | schema:
56 | type: string
57 | enum: [departureTime, -departureTime]
58 | description: |
59 | Sort the results by departure time. Use 'departureTime' for ascending order or '-departureTime' for descending order.
60 | example: departureTime
61 | responses:
62 | '200':
63 | description: Successful operation
64 | content:
65 | application/json:
66 | schema:
67 | type: object
68 | properties:
69 | flights:
70 | type: array
71 | items:
72 | $ref: '#/components/schemas/FlightResponse'
73 | links:
74 | $ref: '#/components/schemas/FlightLinks'
75 | meta:
76 | $ref: '#/components/schemas/Meta'
77 |
78 |
79 | #--------------- postFlight ---------------
80 | post:
81 | tags:
82 | - flight
83 | summary: Create a new flight
84 | description: Create a new flight
85 | operationId: postFlight
86 | requestBody:
87 | description: Flight request body
88 | content:
89 | application/json:
90 | schema:
91 | $ref: '#/components/schemas/Flight'
92 | required: true
93 | responses:
94 | '201':
95 | description: Flight successfully created
96 | content:
97 | application/json:
98 | schema:
99 | $ref: '#/components/schemas/FlightResponse'
100 | '400':
101 | description: Invalid input
102 | '422':
103 | $ref: '#/components/responses/ValidationErrorResponse'
104 |
105 | /flights/{number}:
106 |
107 | #--------------- getFlight ---------------
108 | get:
109 | tags:
110 | - flight
111 | summary: Find a flight by number
112 | description: Find a flight by number
113 | operationId: getFlight
114 | parameters:
115 | - name: number
116 | in: path
117 | description: Number of flight to return
118 | required: true
119 | schema:
120 | $ref: '#/components/schemas/FlightNumber'
121 | responses:
122 | '200':
123 | description: Successful operation
124 | content:
125 | application/json:
126 | schema:
127 | type: object
128 | properties:
129 | flight:
130 | $ref: '#/components/schemas/FlightResponse'
131 | '400':
132 | description: Invalid flight number supplied
133 | '404':
134 | description: Flight not found
135 |
136 | #--------------- deleteFlight ---------------
137 | delete:
138 | tags:
139 | - flight
140 | summary: Delete a flight
141 | description: Delete a flight by number
142 | operationId: deleteFlight
143 | parameters:
144 | - name: number
145 | in: path
146 | description: Number of the flight to delete
147 | required: true
148 | schema:
149 | $ref: '#/components/schemas/FlightNumber'
150 | responses:
151 | '204':
152 | description: Successful operation (no content)
153 | '400':
154 | description: Invalid flight number supplied
155 | '404':
156 | description: Flight not found
157 |
158 | #--------------- putFlight ---------------
159 | put:
160 | tags:
161 | - flight
162 | summary: Fully update an existing flight
163 | description: Fully update an existing flight by number
164 | operationId: putFlight
165 | parameters:
166 | - name: number
167 | in: path
168 | description: Number of flight to update
169 | required: true
170 | schema:
171 | $ref: '#/components/schemas/FlightNumber'
172 | requestBody:
173 | description: Flight request body
174 | content:
175 | application/json:
176 | schema:
177 | $ref: '#/components/schemas/Flight'
178 | required: true
179 | responses:
180 | '200':
181 | description: Successful operation
182 | content:
183 | application/json:
184 | schema:
185 | type: object
186 | properties:
187 | flight:
188 | $ref: '#/components/schemas/FlightResponse'
189 | '400':
190 | description: Invalid flight number supplied
191 | '404':
192 | description: Flight not found
193 | '422':
194 | $ref: '#/components/responses/ValidationErrorResponse'
195 |
196 | #--------------- patchFlight ---------------
197 | patch:
198 | tags:
199 | - flight
200 | summary: Partially update an existing flight
201 | description: Partially update an existing flight by number
202 | operationId: patchFlight
203 | parameters:
204 | - name: number
205 | in: path
206 | description: Number of flight to return
207 | required: true
208 | schema:
209 | $ref: '#/components/schemas/FlightNumber'
210 | requestBody:
211 | description: Flight request body
212 | content:
213 | application/json:
214 | schema:
215 | $ref: '#/components/schemas/Flight'
216 | required: true
217 | responses:
218 | '200':
219 | description: Successful operation
220 | content:
221 | application/json:
222 | schema:
223 | type: object
224 | properties:
225 | flight:
226 | $ref: '#/components/schemas/FlightResponse'
227 | '400':
228 | description: Invalid flight number supplied
229 | '404':
230 | description: Flight not found
231 | '422':
232 | $ref: '#/components/responses/ValidationErrorResponse'
233 |
234 |
235 | /flights/{number}/reservations:
236 |
237 | #--------------- getReservations ---------------
238 | get: # learn how to filter this
239 | tags:
240 | - reservation
241 | summary: Find all reservations
242 | description: Get a paginated list of reservations
243 | operationId: getReservations
244 | parameters:
245 | - in: path
246 | name: number
247 | description: Filter by flight number
248 | required: true
249 | schema:
250 | type: string
251 | $ref: '#/components/schemas/FlightNumber'
252 | - in: query
253 | name: passengerRef
254 | schema:
255 | type: string
256 | description: Filter by passenger reference
257 | - in: query
258 | name: createdAt
259 | schema:
260 | type: string
261 | description: Filter by reservation date (use YYYY-MM-DD)
262 | responses:
263 | '200':
264 | description: Successful operation
265 | content:
266 | application/json:
267 | schema:
268 | type: object
269 | properties:
270 | reservations:
271 | type: array
272 | items:
273 | $ref: '#/components/schemas/ReservationResponse'
274 | links:
275 | $ref: '#/components/schemas/ReservationLinks'
276 | meta:
277 | $ref: '#/components/schemas/Meta'
278 |
279 | #--------------- postReservations ---------------
280 | post:
281 | tags:
282 | - reservation
283 | summary: Create a new reservation
284 | description: Create a new reservation
285 | operationId: postReservations
286 | parameters:
287 | - in: path
288 | name: number
289 | description: Filter by flight number
290 | required: true
291 | schema:
292 | type: string
293 | $ref: '#/components/schemas/FlightNumber'
294 | requestBody:
295 | description: Reservation request body
296 | content:
297 | application/json:
298 | schema:
299 | $ref: '#/components/schemas/Reservation'
300 | required: true
301 | responses:
302 | '201':
303 | description: Reservation successfully created
304 | content:
305 | application/json:
306 | schema:
307 | type: object
308 | properties:
309 | reservation:
310 | type: object
311 | properties:
312 | reference:
313 | type: string
314 | description: Unique identifier for the reservation
315 | flight:
316 | $ref: '#/components/schemas/Flight' # Assuming you have this schema defined elsewhere
317 | passenger:
318 | $ref: '#/components/schemas/Passenger' # Assuming you have this schema defined elsewhere
319 | seatNumber:
320 | type: string
321 | description: Seat number assigned to the passenger
322 | travelClass:
323 | type: string
324 | description: Class of travel for the passenger
325 | createdAt:
326 | type: string
327 | format: date-time
328 | description: Date and time when the reservation was created
329 | cancelledAt:
330 | type: string
331 | format: date-time
332 | nullable: true
333 | description: Date and time when the reservation was cancelled, if applicable
334 | required:
335 | - reservation
336 | '400':
337 | description: Invalid input
338 | '422':
339 | description: Validation exception
340 |
341 | /reservations/{reference}:
342 |
343 | #--------------- getReservation ---------------
344 | get:
345 | tags:
346 | - reservation
347 | summary: Find a reservation by reference
348 | description: Find a reservation by reference
349 | operationId: getReservation
350 | parameters:
351 | - name: reference
352 | in: path
353 | description: Reference of reservation to return
354 | required: true
355 | schema:
356 | $ref: '#/components/schemas/ReservationReference'
357 | responses:
358 | '200':
359 | description: Successful operation
360 | content:
361 | application/json:
362 | schema:
363 | $ref: '#/components/schemas/ReservationResponse'
364 | '400':
365 | description: Invalid reservation reference supplied
366 | '404':
367 | description: Reservation not found
368 |
369 | #--------------- deleteReservation ---------------
370 | delete:
371 | tags:
372 | - reservation
373 | summary: Cancel a reservation by reference
374 | description: Cancel a reservation by reference
375 | operationId: deleteReservation
376 | parameters:
377 | - name: reference
378 | in: path
379 | description: Reference of reservation to cancel
380 | required: true
381 | schema:
382 | $ref: '#/components/schemas/ReservationReference'
383 | responses:
384 | '204':
385 | description: Successful operation (no content)
386 | '400':
387 | description: Invalid reservation reference supplied
388 | '404':
389 | description: Reservation not found
390 |
391 | #--------------- putReservation ---------------
392 | put:
393 | tags:
394 | - reservation
395 | summary: Fully update an existing reservation by reference
396 | description: Fully update an existing reservation by reference
397 | operationId: putReservation
398 | parameters:
399 | - name: reference
400 | in: path
401 | description: Reference of reservation to update
402 | required: true
403 | schema:
404 | $ref: '#/components/schemas/ReservationReference'
405 | requestBody:
406 | description: Reservation request body
407 | content:
408 | application/json:
409 | schema:
410 | $ref: '#/components/schemas/Reservation'
411 | required: true
412 | responses:
413 | '200':
414 | description: Successful operation
415 | content:
416 | application/json:
417 | schema:
418 | $ref: '#/components/schemas/ReservationResponse'
419 | '400':
420 | description: Invalid reservation reference supplied
421 | '404':
422 | description: Reservation not found
423 | '422':
424 | description: Validation exception
425 |
426 | #--------------- patchReservation ---------------
427 | patch:
428 | tags:
429 | - reservation
430 | summary: Partially update an existing reservation by reference
431 | description: Partially update an existing reservation by reference
432 | operationId: patchReservation
433 | parameters:
434 | - name: reference
435 | in: path
436 | description: Reference of reservation to update
437 | required: true
438 | schema:
439 | $ref: '#/components/schemas/ReservationReference'
440 | requestBody:
441 | description: Reservation request body
442 | content:
443 | application/json:
444 | schema:
445 | $ref: '#/components/schemas/Reservation'
446 | required: true
447 | responses:
448 | '200':
449 | description: Successful operation
450 | content:
451 | application/json:
452 | schema:
453 | $ref: '#/components/schemas/ReservationResponse'
454 | '400':
455 | description: Invalid reservation reference supplied
456 | '404':
457 | description: Reservation not found
458 | '422':
459 | description: Validation exception
460 |
461 | /passengers:
462 |
463 | #--------------- getPassengers ---------------
464 | get:
465 | tags:
466 | - passenger
467 | summary: Find all passengers
468 | description: Get a paginated list of passengers
469 | operationId: getPassengers
470 | parameters:
471 | - in: query
472 | name: lastName
473 | schema:
474 | type: string
475 | description: Filter by passenger last name
476 | - in: query
477 | name: passportNumber
478 | schema:
479 | type: string
480 | description: Filter by passport number
481 | - in: query
482 | name: dateOfBirth
483 | schema:
484 | type: string
485 | description: Filter by date of birth
486 | responses:
487 | '200':
488 | description: Successful operation
489 | content:
490 | application/json:
491 | schema:
492 | type: object
493 | properties:
494 | passengers:
495 | type: array
496 | items:
497 | $ref: '#/components/schemas/PassengerResponse'
498 |
499 | #--------------- postPassengers ---------------
500 | post:
501 | tags:
502 | - passenger
503 | summary: Create a new passenger
504 | description: Create a new passenger
505 | operationId: postPassengers
506 | requestBody:
507 | description: Passenger request body
508 | content:
509 | application/json:
510 | schema:
511 | $ref: '#/components/schemas/Passenger'
512 | required: true
513 | responses:
514 | '201':
515 | description: Passenger successfully created
516 | content:
517 | application/json:
518 | schema:
519 | $ref: '#/components/schemas/PassengerResponse'
520 | '400':
521 | description: Invalid input
522 | '422':
523 | description: Validation exception
524 |
525 | /passengers/{reference}:
526 |
527 | #--------------- getPassenger ---------------
528 | get:
529 | tags:
530 | - passenger
531 | summary: Find a passenger by reference
532 | description: Find a passenger by reference
533 | operationId: getPassenger
534 | parameters:
535 | - name: reference
536 | in: path
537 | description: Reference of passenger to return
538 | required: true
539 | schema:
540 | $ref: '#/components/schemas/PassengerReference'
541 | responses:
542 | '200':
543 | description: Successful operation
544 | content:
545 | application/json:
546 | schema:
547 | $ref: '#/components/schemas/ReservationResponse'
548 | '400':
549 | description: Invalid passenger reference supplied
550 | '404':
551 | description: Passenger not found
552 |
553 | #--------------- deletePassenger ---------------
554 | delete:
555 | tags:
556 | - passenger
557 | summary: Delete a passenger by reference
558 | description: Delete a passenger by reference
559 | operationId: deletePassenger
560 | parameters:
561 | - name: reference
562 | in: path
563 | description: Reference of passenger to delete
564 | required: true
565 | schema:
566 | $ref: '#/components/schemas/PassengerReference'
567 | responses:
568 | '204':
569 | description: Successful operation (no content)
570 | '400':
571 | description: Invalid passenger reference supplied
572 | '404':
573 | description: Passenger not found
574 |
575 | #--------------- putPassenger ---------------
576 | put:
577 | tags:
578 | - passenger
579 | summary: Fully update an existing passenger by reference
580 | description: Fully update an existing passenger by reference
581 | operationId: putPassenger
582 | parameters:
583 | - name: reference
584 | in: path
585 | description: Reference of passenger to update
586 | required: true
587 | schema:
588 | $ref: '#/components/schemas/PassengerReference'
589 | requestBody:
590 | description: Passenger request body
591 | content:
592 | application/json:
593 | schema:
594 | $ref: '#/components/schemas/Passenger'
595 | required: true
596 | responses:
597 | '200':
598 | description: Successful operation
599 | content:
600 | application/json:
601 | schema:
602 | $ref: '#/components/schemas/PassengerResponse'
603 | '400':
604 | description: Invalid passenger reference supplied
605 | '404':
606 | description: Passenger not found
607 | '422':
608 | description: Validation exception
609 |
610 | #--------------- patchPassenger ---------------
611 | patch:
612 | tags:
613 | - passenger
614 | summary: Partially update an existing passenger by reference
615 | description: Partially update an existing passenger by reference
616 | operationId: patchPassenger
617 | parameters:
618 | - name: reference
619 | in: path
620 | description: Reference of passenger to return
621 | required: true
622 | schema:
623 | $ref: '#/components/schemas/PassengerReference'
624 | requestBody:
625 | description: Passenger request body
626 | content:
627 | application/json:
628 | schema:
629 | $ref: '#/components/schemas/Passenger'
630 | required: true
631 | responses:
632 | '200':
633 | description: Successful operation
634 | content:
635 | application/json:
636 | schema:
637 | $ref: '#/components/schemas/PassengerResponse'
638 | '400':
639 | description: Invalid passenger reference supplied
640 | '404':
641 | description: Passenger not found
642 | '422':
643 | description: Validation exception
644 |
645 | components:
646 | schemas:
647 |
648 | #--------------- FlightNumber ---------------
649 | FlightNumber:
650 | type: string
651 | pattern: '^[A-Z]{2}\d{4}-\d{8}$'
652 | example: 'JF1011-20240122'
653 |
654 | #--------------- Flight ---------------
655 | Flight:
656 | type: object
657 | properties:
658 | origin:
659 | type: string
660 | description: Origin location code
661 | example: AAA
662 | destination:
663 | type: string
664 | description: Destination location code
665 | example: AAA
666 | departureTime:
667 | type: string
668 | format: date-time
669 | example: '2024-01-22T13:45:00+00:00'
670 | arrivalTime:
671 | type: string
672 | format: date-time
673 | example: '2024-01-22T13:45:00+00:00'
674 |
675 | #--------------- FlightResponse ---------------
676 | FlightResponse:
677 | allOf:
678 | - type: object
679 | properties:
680 | number:
681 | $ref: '#/components/schemas/FlightNumber'
682 | required:
683 | - number
684 | - $ref: '#/components/schemas/Flight'
685 |
686 | #--------------- ReservationReference ---------------
687 | ReservationReference:
688 | type: string
689 | description: A unique identifier for the reservation
690 | example: 'AB1234ABCD12'
691 |
692 | #--------------- Reservation ---------------
693 | Reservation:
694 | type: object
695 | properties:
696 | flightNumber:
697 | $ref: '#/components/schemas/FlightNumber'
698 | passengerRef:
699 | $ref: '#/components/schemas/PassengerReference'
700 | seatNumber:
701 | type: string
702 | example: '12A'
703 | travelClass:
704 | type: string
705 | example: Economy
706 | enum:
707 | - Economy
708 | - Business
709 | - First
710 | createdAt:
711 | type: string
712 | format: date-time
713 | example: '2024-01-22T13:45:00+00:00'
714 | cancelledAt:
715 | type: string
716 | format: date-time
717 | nullable: true
718 | example: '2024-01-22T13:45:00+00:00'
719 |
720 | #--------------- ReservationResponse ---------------
721 | ReservationResponse:
722 | allOf:
723 | - type: object
724 | properties:
725 | reference:
726 | $ref: '#/components/schemas/ReservationReference'
727 | required:
728 | - reference
729 | - $ref: '#/components/schemas/Reservation'
730 |
731 | #--------------- PassengerReference ---------------
732 | PassengerReference:
733 | type: string
734 | description: A unique identifier for the passenger
735 | example: 'AB1234ABCD12'
736 |
737 | #--------------- Passenger ---------------
738 | Passenger:
739 | type: object
740 | required:
741 | - firstName
742 | - lastName
743 | - dateOfBirth
744 | properties:
745 | firstNames:
746 | type: string
747 | example: 'Adam Philip'
748 | lastName:
749 | type: string
750 | example: 'Cooper'
751 | passportNumber:
752 | type: string
753 | example: 'C01234567'
754 | dateOfBirth:
755 | type: string
756 | format: 'date'
757 | example: '1977-06-09'
758 | gender:
759 | type: string
760 | enum: [ 'Male', 'Female', 'Other' ]
761 | example: 'Male'
762 | nationality:
763 | type: string
764 | deprecated: true
765 | description: 'Deprecated: Please use countryOfOrigin instead. This field will be removed in the future.'
766 | example: 'UK'
767 | countryOfOrigin:
768 | type: string
769 | description: 'The country of origin of the passenger, replacing the deprecated nationality field.'
770 | example: 'United Kingdom'
771 |
772 |
773 | #--------------- PassengerResponse ---------------
774 | PassengerResponse:
775 | allOf:
776 | - type: object
777 | properties:
778 | reference:
779 | $ref: '#/components/schemas/PassengerReference'
780 | required:
781 | - reference
782 | - $ref: '#/components/schemas/Passenger'
783 |
784 | #--------------- FlightLinks ---------------
785 | FlightLinks:
786 | type: object
787 | properties:
788 | self:
789 | type: string
790 | format: uri
791 | example: /flights?page=2
792 | description: The link to the current page of results
793 | # Pagination
794 | first:
795 | type: string
796 | format: uri
797 | example: /flights?page=1
798 | description: The link to the first page of results
799 | prev:
800 | type: string
801 | format: uri
802 | example: /flights?page=1
803 | description: The link to the previous page of results, if any
804 | next:
805 | type: string
806 | format: uri
807 | example: /flights?page=3
808 | description: The link to the next page of results, if any
809 | last:
810 | type: string
811 | format: uri
812 | example: /flights?page=5
813 | description: The link to the last page of results
814 |
815 | #--------------- ReservationLinks ---------------
816 |
817 | ReservationLinks:
818 | type: object
819 | properties:
820 | self:
821 | type: string
822 | format: uri
823 | example: /flights/{number}/reservations?page=2
824 | description: The link to the current page of results
825 | # Pagination
826 | first:
827 | type: string
828 | format: uri
829 | example: /flights/{number}/reservations?page=1
830 | description: The link to the first page of results
831 | prev:
832 | type: string
833 | format: uri
834 | example: /flights/{number}/reservations?page=1
835 | description: The link to the previous page of results, if any
836 | next:
837 | type: string
838 | format: uri
839 | example: /flights/{number}/reservations?page=3
840 | description: The link to the next page of results, if any
841 | last:
842 | type: string
843 | format: uri
844 | example: /flights/{number}/reservations?page=5
845 | description: The link to the last page of results
846 |
847 | #--------------- Meta ---------------
848 | Meta:
849 | type: object
850 | properties:
851 | totalItems:
852 | type: integer
853 | example: 100
854 | description: The total number of items
855 | totalPages:
856 | type: integer
857 | example: 5
858 | description: The total number of pages
859 | currentPage:
860 | type: integer
861 | example: 1
862 | description: The current page number
863 | itemsPerPage:
864 | type: integer
865 | example: 20
866 | description: The number of items per page
867 |
868 | responses:
869 |
870 | #--------------- ValidationErrorResponse ---------------
871 |
872 | ValidationErrorResponse:
873 | description: Validation exception
874 | content:
875 | application/problem+json:
876 | schema:
877 | type: object
878 | properties:
879 | type:
880 | type: string
881 | example: "https://www.rfc-editor.org/rfc/rfc9110.html#name-422-unprocessable-content"
882 | title:
883 | type: string
884 | example: "422 Unprocessable content"
885 | detail:
886 | type: string
887 | example: "The request could not be processed by this server"
888 | instance:
889 | type: string
890 | example: "/flights"
891 | errors:
892 | type: array
893 | items:
894 | type: object
895 | properties:
896 | property:
897 | type: string
898 | example: "origin"
899 | message:
900 | type: string
901 | example: "This value should not be blank."
902 | required:
903 | - type
904 | - title
905 | - detail
906 | - instance
907 | - errors
908 |
909 |
910 | securitySchemes:
911 | BearerAuth:
912 | type: http
913 | scheme: bearer
914 | bearerFormat: JWT
915 | description: Enter JWT token to authorize
916 |
917 | security:
918 | - BearerAuth: [ ] # Apply the BearerAuth security scheme globally
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | ignoreErrors:
3 | -
4 | message: '#Cannot call method format\(\) on mixed#'
5 | path: src/Controller/*
6 | -
7 | message: '#Cannot access property \$value on mixed#'
8 | path: src/Http/Middleware/ContentNegotiation/ContentTypeMiddleware.php
--------------------------------------------------------------------------------
/public/html/api-changelog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Changelog for API v2
7 |
8 |
9 | Changelog for API v2
10 |
11 | Added
12 |
13 | - New endpoint
/api/v2/new-feature
to retrieve additional data.
14 |
15 |
16 | Deprecated
17 |
18 | /api/v1/old-feature
will be deprecated as of 2023-01-01; see migration guide for alternatives.
19 |
20 |
21 | Removed
22 |
23 | - Removed
old_parameter
from /api/v2/resource
; replaced with new_parameter
.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/html/migration-guide.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Migration Guide from v1 to v2
7 |
8 |
9 | Migration Guide from v1 to v2
10 |
11 | Transitioning from /api/v1/old-feature
to /api/v2/new-feature
12 |
13 | Request changes:
14 |
15 | GET /api/v1/old-feature?param=value
16 |
17 |
18 | GET /api/v2/new-feature?new_param=new_value
19 |
20 |
21 | Response changes:
22 | Previous response:
23 |
24 | {
25 | "old_key": "old_value"
26 | }
27 |
28 | Is now:
29 |
30 | {
31 | "new_key": "new_value"
32 | }
33 |
34 |
35 | Handling Deprecated Endpoints
36 | The deprecated endpoints will remain functional until 2023-01-01, allowing time for transition. Contact support for
37 | assistance.
38 |
39 |
40 |
--------------------------------------------------------------------------------
/public/index.php:
--------------------------------------------------------------------------------
1 | run();
17 |
--------------------------------------------------------------------------------
/routes/api.php:
--------------------------------------------------------------------------------
1 | getRouteCollector();
16 | $routeCollector->setDefaultInvocationStrategy(new RequestResponseArgs());
17 |
18 | $container = $app->getContainer();
19 |
20 | $app->group('/v1', function (\Slim\Routing\RouteCollectorProxy $group) use ($container) {
21 |
22 | // Define routes
23 | $group->get('/healthcheck', function (Request $request, Response $response) {
24 | $payload = json_encode(['app' => true]);
25 | $response->getBody()->write($payload);
26 | return $response->withHeader('Content-Type', 'application/json');
27 | });
28 |
29 | $group->group('/flights', function (\Slim\Routing\RouteCollectorProxy $group) use ($container) {
30 | $group->get('', [\App\Controller\FlightsController::class, 'index']);
31 |
32 | $group->get(
33 | '/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}',
34 | [\App\Controller\FlightsController::class, 'show']
35 | )->addMiddleware(new \App\Http\Middleware\Cache\HttpCacheMiddleware(
36 | cacheControl: ['public', 'max-age=600', 'must-revalidate'],
37 | expires: 600,
38 | vary: ['Accept-Encoding']
39 | ));
40 |
41 | $group->post('', [\App\Controller\FlightsController::class, 'store']);
42 |
43 | $group->delete(
44 | '/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}',
45 | [\App\Controller\FlightsController::class, 'destroy']
46 | );
47 |
48 | $group->put(
49 | '/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}',
50 | [\App\Controller\FlightsController::class, 'update']
51 | );
52 |
53 | $group->patch(
54 | '/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}',
55 | [\App\Controller\FlightsController::class, 'update']
56 | );
57 | });
58 |
59 | $group->group('/passengers', function (\Slim\Routing\RouteCollectorProxy $group) {
60 | $group->get('', [\App\Controller\PassengersController::class, 'index']);
61 |
62 | $group->get(
63 | '/{reference:[0-9]+[A-Z]{3}}',
64 | [\App\Controller\PassengersController::class, 'show']
65 | );
66 |
67 | $group->post('', [\App\Controller\PassengersController::class, 'store']);
68 |
69 | $group->delete(
70 | '/{reference:[0-9]+[A-Z]{3}}',
71 | [\App\Controller\PassengersController::class, 'destroy']
72 | );
73 |
74 | $group->put(
75 | '/{reference:[0-9]+[A-Z]{3}}',
76 | [\App\Controller\PassengersController::class, 'update']
77 | );
78 |
79 | $group->patch(
80 | '/{reference:[0-9]+[A-Z]{3}}',
81 | [\App\Controller\PassengersController::class, 'update']
82 | )->addMiddleware(new \App\Http\Middleware\Deprecations\SunsetHeaderMiddleware(
83 | '2026-01-01'
84 | ));
85 | });
86 |
87 | $group->group('', function (\Slim\Routing\RouteCollectorProxy $group) use ($container) {
88 | // GET collection /flights//reservations
89 | $group->get(
90 | '/flights/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}/reservations',
91 | [\App\Controller\ReservationsController::class, 'index']
92 | )->addMiddleware(new \App\Http\Middleware\Cache\ContentCacheMiddleware(
93 | $container->get(\Symfony\Contracts\Cache\CacheInterface::class)
94 | ));
95 |
96 | // POST /flights//reservations
97 | $group->post(
98 | '/flights/{number:[A-Za-z]{2}[0-9]{1,4}-[0-9]{8}}/reservations',
99 | [\App\Controller\ReservationsController::class, 'store']
100 | )->addMiddleware(
101 | new \App\Http\Middleware\Security\PermissionsMiddleware(
102 | $container->get(\App\Security\AccessControlManager::class),
103 | \App\Security\AccessControlManager::CREATE_RESERVATION
104 | )
105 | )->addMiddleware(
106 | new \App\Http\Middleware\Security\JwtAuthenticationMiddleware(
107 | $container->get(\App\Security\TokenAuthenticator::class)
108 | ));
109 |
110 | // GET item, DELETE, PUT, PATCH
111 | // reservations/
112 | $group->get(
113 | '/reservations/{reference:[0-9]+JF[0-9]{4}}',
114 | [\App\Controller\ReservationsController::class, 'show']
115 | );
116 |
117 | $group->delete(
118 | '/reservations/{reference:[0-9]+JF[0-9]{4}}',
119 | [\App\Controller\ReservationsController::class, 'destroy']
120 | );
121 |
122 | $group->map(
123 | ['PUT', 'PATCH'],
124 | '/reservations/{reference:[0-9]+JF[0-9]{4}}',
125 | [\App\Controller\ReservationsController::class, 'update']
126 | )->addMiddleware(new \App\Http\Middleware\Security\JwtAuthenticationMiddleware(
127 | $container->get(\App\Security\TokenAuthenticator::class)
128 | ));
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/sql/add-ownerId-to-reservations.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE reservations ADD ownerId VARCHAR(255) NOT NULL;
--------------------------------------------------------------------------------
/sql/flights-query-params.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO `flights-api`.`flights` (`id`, `number`, `origin`, `destination`, `departure_time`, `arrival_time`) VALUES (DEFAULT, 'JF1001-20250102', 'ABC', 'DEF', '2025-01-02 08:00:00', '2025-01-02 10:00:00');
2 | INSERT INTO `flights-api`.`flights` (`id`, `number`, `origin`, `destination`, `departure_time`, `arrival_time`) VALUES (DEFAULT, 'JF1010-20250110', 'ABC', 'FGH', '2025-01-01 17:00:00', '2025-01-01 19:30:00');
3 | INSERT INTO `flights-api`.`flights` (`id`, `number`, `origin`, `destination`, `departure_time`, `arrival_time`) VALUES (DEFAULT, 'JF1001-20250102', 'ABC', 'DEF', '2025-01-03 08:00:00', '2025-01-03 10:00:00');
--------------------------------------------------------------------------------
/sql/flights.sql:
--------------------------------------------------------------------------------
1 | -- 4-flight-entity
2 |
3 | CREATE TABLE flights (id INT AUTO_INCREMENT NOT NULL, number VARCHAR(8) NOT NULL, origin VARCHAR(3) NOT NULL, destination VARCHAR(3) NOT NULL, departure_time DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', arrival_time DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
4 |
5 | INSERT INTO flights (number, origin, destination, departure_time, arrival_time)
6 | VALUES
7 | ('JF1001-20240121', 'ABC', 'DEF', '2024-01-21 08:00:00', '2024-01-21 10:00:00'),
8 | ('JF1002-20240121', 'GHI', 'JKL', '2024-01-21 09:00:00', '2024-01-21 11:30:00'),
9 | ('JF1003-20240121', 'MNO', 'PQR', '2024-01-21 10:00:00', '2024-01-21 12:00:00'),
10 | ('JF1004-20240121', 'STU', 'VWX', '2024-01-21 11:00:00', '2024-01-21 13:30:00'),
11 | ('JF1005-20240121', 'YZA', 'BCD', '2024-01-21 12:00:00', '2024-01-21 14:00:00'),
12 | ('JF1006-20240121', 'EFG', 'HIJ', '2024-01-21 13:00:00', '2024-01-21 15:30:00'),
13 | ('JF1007-20240121', 'KLM', 'NOP', '2024-01-21 14:00:00', '2024-01-21 16:00:00'),
14 | ('JF1008-20240121', 'QRS', 'TUV', '2024-01-21 15:00:00', '2024-01-21 17:30:00'),
15 | ('JF1009-20240121', 'WXY', 'ZAB', '2024-01-21 16:00:00', '2024-01-21 18:00:00'),
16 | ('JF1010-20240121', 'CDE', 'FGH', '2024-01-21 17:00:00', '2024-01-21 19:30:00');
17 |
--------------------------------------------------------------------------------
/sql/index-cancelled-at.sql:
--------------------------------------------------------------------------------
1 | CREATE INDEX cancelled_at_idx ON reservations (cancelledAt);
--------------------------------------------------------------------------------
/sql/passengers.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE passengers (id INT AUTO_INCREMENT NOT NULL, reference VARCHAR(20) NOT NULL, firstNames VARCHAR(255) NOT NULL, lastName VARCHAR(255) NOT NULL, passportNumber VARCHAR(20) DEFAULT NULL, dateOfBirth DATE NOT NULL, nationality VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_E3578E8AAEA34913 (reference), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
2 |
3 | ALTER TABLE passengers ADD countryOfOrigin VARCHAR(255) DEFAULT NULL;
4 |
5 | UPDATE passengers SET countryOfOrigin = nationality;
--------------------------------------------------------------------------------
/sql/reservations.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE reservations (id INT AUTO_INCREMENT NOT NULL, flight_id INT DEFAULT NULL, passenger_id INT DEFAULT NULL, reference VARCHAR(20) NOT NULL, seatNumber VARCHAR(10) NOT NULL, travelClass VARCHAR(20) NOT NULL, createdAt DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', cancelledAt DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_4DA239AEA34913 (reference), INDEX IDX_4DA23991F478C5 (flight_id), INDEX IDX_4DA2394502E565 (passenger_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
2 | ALTER TABLE reservations ADD CONSTRAINT FK_4DA23991F478C5 FOREIGN KEY (flight_id) REFERENCES flights (id);
3 | ALTER TABLE reservations ADD CONSTRAINT FK_4DA2394502E565 FOREIGN KEY (passenger_id) REFERENCES passengers (id);
--------------------------------------------------------------------------------
/sql/seed-tables.sql:
--------------------------------------------------------------------------------
1 | -- Drop the 'reservations' table first because it has foreign keys referencing the other two tables
2 | DROP TABLE IF EXISTS reservations;
3 |
4 | -- Then drop the 'flights' and 'passengers' tables
5 | DROP TABLE IF EXISTS flights;
6 | DROP TABLE IF EXISTS passengers;
7 |
8 | -- Create the flights table
9 | CREATE TABLE flights (id INT AUTO_INCREMENT NOT NULL, number VARCHAR(20) NOT NULL, origin VARCHAR(3) NOT NULL, destination VARCHAR(3) NOT NULL, departure_time DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', arrival_time DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
10 |
11 | -- Create the passengers table
12 | CREATE TABLE passengers (id INT AUTO_INCREMENT NOT NULL, reference VARCHAR(20) NOT NULL, firstNames VARCHAR(255) NOT NULL, lastName VARCHAR(255) NOT NULL, passportNumber VARCHAR(20) DEFAULT NULL, dateOfBirth DATE NOT NULL, nationality VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_E3578E8AAEA34913 (reference), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
13 |
14 | -- Create the reservations table
15 | CREATE TABLE reservations (id INT AUTO_INCREMENT NOT NULL, flight_id INT DEFAULT NULL, passenger_id INT DEFAULT NULL, reference VARCHAR(20) NOT NULL, seatNumber VARCHAR(10) NOT NULL, travelClass VARCHAR(20) NOT NULL, createdAt DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', cancelledAt DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_4DA239AEA34913 (reference), INDEX IDX_4DA23991F478C5 (flight_id), INDEX IDX_4DA2394502E565 (passenger_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
16 | ALTER TABLE reservations ADD CONSTRAINT FK_4DA23991F478C5 FOREIGN KEY (flight_id) REFERENCES flights (id);
17 | ALTER TABLE reservations ADD CONSTRAINT FK_4DA2394502E565 FOREIGN KEY (passenger_id) REFERENCES passengers (id);
18 |
19 | -- Seed the flights table
20 | INSERT INTO flights (number, origin, destination, departure_time, arrival_time)
21 | VALUES
22 | ('JF1001-20250101', 'ABC', 'DEF', '2025-01-01 08:00:00', '2025-01-01 10:00:00'),
23 | ('JF1002-20250102', 'GHI', 'JKL', '2025-01-02 09:00:00', '2025-01-02 11:30:00'),
24 | ('JF1003-20250103', 'MNO', 'PQR', '2025-01-03 10:00:00', '2025-01-03 12:00:00'),
25 | ('JF1004-20250104', 'STU', 'VWX', '2025-01-04 11:00:00', '2025-01-04 13:30:00'),
26 | ('JF1005-20250105', 'YZA', 'BCD', '2025-01-05 12:00:00', '2025-01-05 14:00:00'),
27 | ('JF1006-20250106', 'EFG', 'HIJ', '2025-01-06 13:00:00', '2025-01-06 15:30:00'),
28 | ('JF1007-20250107', 'KLM', 'NOP', '2025-01-07 14:00:00', '2025-01-07 16:00:00'),
29 | ('JF1008-20250108', 'QRS', 'TUV', '2025-01-08 15:00:00', '2025-01-08 17:30:00'),
30 | ('JF1009-20250109', 'WXY', 'ZAB', '2025-01-09 16:00:00', '2025-01-09 18:00:00'),
31 | ('JF1010-20250110', 'CDE', 'FGH', '2025-01-10 17:00:00', '2025-01-10 19:30:00');
32 |
33 | -- Seed the passengers table
34 | INSERT INTO passengers (reference, firstNames, lastName, passportNumber, dateOfBirth, nationality)
35 | VALUES
36 | ('1713363801SMI', 'John', 'Smith', 'PS100001', '1980-05-15', 'USA'),
37 | ('1713363802DOE', 'Jane', 'Doe', 'PS100002', '1985-08-22', 'UK'),
38 | ('1713363803LEE', 'Bruce', 'Lee', 'PS100003', '1973-11-27', 'Canada'),
39 | ('1713363804KHA', 'Amir', 'Khan', 'PS100004', '1992-01-12', 'India'),
40 | ('1713363805BRO', 'Charlie', 'Brown', 'PS100005', '1990-07-09', 'USA'),
41 | ('1713363806JOH', 'Michael', 'Johnson', 'PS100006', '1975-02-17', 'Australia'),
42 | ('1713363807WIL', 'Jessica', 'Wilson', 'PS100007', '1988-03-23', 'UK'),
43 | ('1713363808GAR', 'Elena', 'Garcia', 'PS100008', '1994-12-15', 'Spain'),
44 | ('1713363809CHA', 'Sophie', 'Chapman', 'PS100009', '1987-05-25', 'France'),
45 | ('1713363810MAR', 'Lucas', 'Martin', 'PS100010', '1991-09-30', 'Germany'),
46 | ('1713363811RIC', 'Julia', 'Richards', 'PS100011', '1983-04-11', 'Canada'),
47 | ('1713363812MOR', 'Edward', 'Moore', 'PS100012', '1979-07-19', 'USA'),
48 | ('1713363813DAV', 'Olivia', 'Davis', 'PS100013', '1986-10-28', 'Australia'),
49 | ('1713363814MIL', 'George', 'Miller', 'PS100014', '1993-02-05', 'New Zealand'),
50 | ('1713363815AND', 'Isabella', 'Anderson', 'PS100015', '1984-12-03', 'UK'),
51 | ('1713363816TAY', 'Matthew', 'Taylor', 'PS100016', '1990-03-07', 'USA'),
52 | ('1713363817THO', 'Liam', 'Thompson', 'PS100017', '1978-01-26', 'Ireland'),
53 | ('1713363818WAL', 'Grace', 'Walker', 'PS100018', '1992-11-09', 'UK'),
54 | ('1713363819LEE', 'Hannah', 'Lee', 'PS100019', '1995-06-20', 'South Korea'),
55 | ('1713363820KIM', 'David', 'Kim', 'PS100020', '1989-04-14', 'South Korea'),
56 | ('1713363821LI', 'Xin', 'Li', 'PS100021', '1974-03-15', 'China'),
57 | ('1713363822ZHA', 'Mei', 'Zhao', 'PS100022', '1981-08-22', 'China'),
58 | ('1713363823QUE', 'Carlos', 'Quero', 'PS100023', '1987-07-13', 'Mexico'),
59 | ('1713363824FUE', 'Maria', 'Fuente', 'PS100024', '1994-10-17', 'Spain'),
60 | ('1713363825SIL', 'Pedro', 'Silva', 'PS100025', '1980-02-26', 'Brazil'),
61 | ('1713363826ROS', 'Anna', 'Rossi', 'PS100026', '1977-11-03', 'Italy'),
62 | ('1713363827BER', 'Nora', 'Bernard', 'PS100027', '1983-06-14', 'France'),
63 | ('1713363828VAN', 'Willem', 'Van Dijk', 'PS100028', '1976-09-29', 'Netherlands'),
64 | ('1713363829HOL', 'Sven', 'Holm', 'PS100029', '1971-05-07', 'Sweden'),
65 | ('1713363830KOV', 'Ivan', 'Kovac', 'PS100030', '1965-12-18', 'Croatia');
66 |
67 | -- Seed the reservations table
68 | INSERT INTO reservations (flight_id, passenger_id, reference, seatNumber, travelClass, createdAt)
69 | VALUES
70 | (1, 1, '1713363801JF1001', '1A', 'Economy', '2025-01-01 08:00:00'),
71 | (1, 2, '1713363802JF1001', '1B', 'First', '2025-01-01 08:05:00'),
72 | (1, 3, '1713363803JF1001', '1C', 'Business', '2025-01-01 08:10:00'),
73 | (1, 4, '1713363804JF1001', '1D', 'Economy', '2025-01-01 08:15:00'),
74 | (1, 5, '1713363805JF1001', '1E', 'First', '2025-01-01 08:20:00'),
75 | (1, 6, '1713363806JF1001', '1F', 'Business', '2025-01-01 08:25:00'),
76 | (1, 7, '1713363807JF1001', '2A', 'Economy', '2025-01-01 08:30:00'),
77 | (1, 8, '1713363808JF1001', '2B', 'First', '2025-01-01 08:35:00'),
78 | (1, 9, '1713363809JF1001', '2C', 'Business', '2025-01-01 08:40:00'),
79 | (1, 10, '1713363810JF1001', '2D', 'Economy', '2025-01-01 08:45:00'),
80 | (1, 11, '1713363811JF1001', '2E', 'First', '2025-01-01 08:50:00'),
81 | (1, 12, '1713363812JF1001', '2F', 'Business', '2025-01-01 08:55:00'),
82 | (1, 13, '1713363813JF1001', '3A', 'Economy', '2025-01-01 09:00:00'),
83 | (1, 14, '1713363814JF1001', '3B', 'First', '2025-01-01 09:05:00'),
84 | (1, 15, '1713363815JF1001', '3C', 'Business', '2025-01-01 09:10:00'),
85 | (1, 16, '1713363816JF1001', '3D', 'Economy', '2025-01-01 09:15:00'),
86 | (1, 17, '1713363817JF1001', '3E', 'First', '2025-01-01 09:20:00'),
87 | (1, 18, '1713363818JF1001', '3F', 'Business', '2025-01-01 09:25:00'),
88 | (1, 19, '1713363819JF1001', '4A', 'Economy', '2025-01-01 09:30:00'),
89 | (1, 20, '1713363820JF1001', '4B', 'First', '2025-01-01 09:35:00'),
90 | (1, 21, '1713363821JF1001', '4C', 'Business', '2025-01-01 09:40:00'),
91 | (1, 22, '1713363822JF1001', '4D', 'Economy', '2025-01-01 09:45:00'),
92 | (1, 23, '1713363823JF1001', '4E', 'First', '2025-01-01 09:50:00'),
93 | (1, 24, '1713363824JF1001', '4F', 'Business', '2025-01-01 09:55:00'),
94 | (1, 25, '1713363825JF1001', '5A', 'Economy', '2025-01-01 10:00:00'),
95 | (1, 26, '1713363826JF1001', '5B', 'First', '2025-01-01 10:05:00'),
96 | (1, 27, '1713363827JF1001', '5C', 'Business', '2025-01-01 10:10:00'),
97 | (1, 28, '1713363828JF1001', '5D', 'Economy', '2025-01-01 10:15:00'),
98 | (1, 29, '1713363829JF1001', '5E', 'First', '2025-01-01 10:20:00'),
99 | (1, 30, '1713363830JF1001', '5F', 'Business', '2025-01-01 10:25:00');
100 |
101 |
--------------------------------------------------------------------------------
/src/Controller/ApiController.php:
--------------------------------------------------------------------------------
1 | getQueryParams();
33 |
34 | $totalItems = $this->flightRepository->countFilteredFlights($filters);
35 |
36 | $paginationMetadata = PaginationMetadataFactory::create($request, $totalItems);
37 |
38 | // Retrieve the flights
39 | $flights = $this->flightRepository->findFlights(
40 | $paginationMetadata->page,
41 | $paginationMetadata->itemsPerPage,
42 | $filters
43 | );
44 |
45 | // Serialize the flights
46 | $jsonFlights = $this->serializer->serialize(
47 | [
48 | 'flights' => $flights,
49 | 'links' => $paginationMetadata->links,
50 | 'meta' => $paginationMetadata->meta
51 | ]
52 | );
53 |
54 | // Return the response containing the flights
55 | $response->getBody()->write($jsonFlights);
56 |
57 | return $response->withHeader('Cache-Control', 'public, max-age=600');
58 | }
59 |
60 | public function show(Request $request, Response $response, string $number): Response
61 | {
62 | $flight = $this->entityManager->getRepository(Flight::class)
63 | ->findOneBy(['number' => $number]);
64 |
65 | if (!$flight) {
66 | return $response->withStatus(StatusCodeInterface::STATUS_NOT_FOUND);
67 | }
68 |
69 | // Serialize the flights
70 | $jsonFlight = $this->serializer->serialize(
71 | ['flight' => $flight],
72 | $request->getAttribute('content-type')->format()
73 | );
74 |
75 | $response->getBody()->write($jsonFlight);
76 |
77 | return $response->withHeader('Cache-Control', 'public, max-age=600');
78 | }
79 |
80 | public function store(Request $request, Response $response): Response
81 | {
82 | // Grab the post data
83 | $flightJson = $request->getBody()->getContents();
84 |
85 | // deserialize into a flight
86 | $flight = $this->serializer->deserialize(
87 | $flightJson,
88 | Flight::class
89 | );
90 |
91 | // Validate the post data (happy path for now..save for Error Handling section)
92 | $this->validator->validate($flight, $request, [Flight::CREATE_GROUP]);
93 |
94 | // Save the flight to the DB
95 | $this->entityManager->persist($flight);
96 | $this->entityManager->flush();
97 |
98 | // Serialize the new flight
99 | $jsonFlight = $this->serializer->serialize(
100 | ['flight' => $flight]
101 | );
102 |
103 | // Add the flight to the response body
104 | $response->getBody()->write($jsonFlight);
105 |
106 | // Return the response
107 | return $response->withStatus(StatusCodeInterface::STATUS_CREATED);
108 | }
109 |
110 | public function destroy(Request $request, Response $response, string $number): Response
111 | {
112 | // Query for the flight with the number: $number
113 | $flight = $this->entityManager->getRepository(Flight::class)
114 | ->findOneBy(['number' => $number]);
115 |
116 | // Handle not found resource
117 | if (!$flight) {
118 | return $response->withStatus(StatusCodeInterface::STATUS_NOT_FOUND);
119 | }
120 |
121 | // Remove from DB
122 | $this->entityManager->remove($flight);
123 | $this->entityManager->flush();
124 |
125 | // Return response with no content status code
126 | return $response->withStatus(StatusCodeInterface::STATUS_NO_CONTENT);
127 | }
128 |
129 | public function update(Request $request, Response $response, string $number): Response
130 | {
131 | // Retrieve flight using flight number
132 | $flight = $this->entityManager->getRepository(Flight::class)
133 | ->findOneBy(['number' => $number]);
134 |
135 | // Return not found if necessary
136 | if (!$flight) {
137 | return $response->withStatus(StatusCodeInterface::STATUS_NOT_FOUND);
138 | }
139 |
140 | // Grab the post data and map to the flight
141 | $flightJson = $request->getBody()->getContents();
142 |
143 | $flight = $this->serializer->deserialize(
144 | data: $flightJson,
145 | type: Flight::class,
146 | context: [
147 | AbstractNormalizer::OBJECT_TO_POPULATE => $flight,
148 | AbstractNormalizer::IGNORED_ATTRIBUTES => ['number']
149 | ]
150 | );
151 |
152 | // Validate the post data (happy path for now..save for Error Handling section)
153 | $this->validator->validate($flight, $request, [Flight::UPDATE_GROUP]);
154 |
155 | // Persist
156 | $this->entityManager->persist($flight);
157 | $this->entityManager->flush();
158 |
159 | // Serialize the updated flight
160 | $jsonFlight = $this->serializer->serialize(
161 | ['flight' => $flight]
162 | );
163 |
164 | // Add the flight to the response body
165 | $response->getBody()->write($jsonFlight);
166 |
167 | return $response;
168 | }
169 | }
170 |
171 |
172 |
173 |
174 |
175 |
176 |
--------------------------------------------------------------------------------
/src/Controller/PassengersController.php:
--------------------------------------------------------------------------------
1 | entityManager->getRepository(Passenger::class)
18 | ->findAll();
19 |
20 | $jsonPassengers = $this->serializer->serialize(
21 | ['passengers' => $passengers]
22 | );
23 |
24 | $response->getBody()->write($jsonPassengers);
25 |
26 | return $response->withHeader('Cache-Control', 'public, max-age=600');
27 | }
28 |
29 | public function show(Request $request, Response $response, string $reference): Response
30 | {
31 | $passenger = $this->entityManager->getRepository(Passenger::class)
32 | ->findOneBy(['reference' => $reference]);
33 |
34 | $jsonPassenger = $this->serializer->serialize(
35 | ['passenger' => $passenger]
36 | );
37 |
38 | $response->getBody()->write($jsonPassenger);
39 |
40 | return $response;
41 | }
42 |
43 | public function store(Request $request, Response $response): Response
44 | {
45 | // Grab the post data and map to a passenger
46 | $passengerJson = $request->getBody()->getContents();
47 |
48 | $passenger = $this->serializer->deserialize(
49 | $passengerJson,
50 | Passenger::class
51 | );
52 |
53 | if (!$passenger->getCountryOfOrigin()) {
54 | $passenger->setCountryOfOrigin($passenger->getNationality());
55 | }
56 |
57 | assert($passenger instanceof Passenger);
58 |
59 | $passenger->setReference(time() . strtoupper(substr($passenger->getLastName(), 0, 3)));
60 |
61 | $this->validator->validate($passenger, $request);
62 |
63 | // Save the post data
64 | $this->entityManager->persist($passenger);
65 | $this->entityManager->flush();
66 |
67 | // Serialize the new passenger
68 | $jsonPassenger = $this->serializer->serialize(
69 | ['passenger' => $passenger],
70 | $request->getAttribute('content-type')->format()
71 | );
72 |
73 | // Add the passenger to the response body
74 | $response->getBody()->write($jsonPassenger);
75 |
76 | // Return the response
77 | return $response->withStatus(StatusCodeInterface::STATUS_CREATED);
78 | }
79 |
80 | public function destroy(Request $request, Response $response, string $reference): Response
81 | {
82 | $passenger = $this->entityManager->getRepository(Passenger::class)
83 | ->findOneBy(['reference' => $reference]);
84 |
85 | if ($passenger === null) {
86 | return $response->withStatus(StatusCodeInterface::STATUS_NOT_FOUND);
87 | }
88 |
89 | $this->entityManager->remove($passenger);
90 | $this->entityManager->flush();
91 |
92 | return $response->withStatus(StatusCodeInterface::STATUS_NO_CONTENT);
93 | }
94 |
95 | public function update(Request $request, Response $response, string $reference): Response
96 | {
97 | $passenger = $this->entityManager->getRepository(Passenger::class)
98 | ->findOneBy(['reference' => $reference]);
99 |
100 | if ($passenger === null) {
101 | return $response->withStatus(StatusCodeInterface::STATUS_NOT_FOUND);
102 | }
103 |
104 | // Grab the post data and map to a passenger
105 | $passengerJson = $request->getBody()->getContents();
106 |
107 | // Deserialize
108 | $passenger = $this->serializer->deserialize(
109 | data: $passengerJson,
110 | type: Passenger::class,
111 | context: [
112 | AbstractNormalizer::OBJECT_TO_POPULATE => $passenger,
113 | AbstractNormalizer::IGNORED_ATTRIBUTES => ['reference']
114 | ]
115 | );
116 |
117 | $this->validator->validate($passenger, $request);
118 |
119 | // Persist
120 | $this->entityManager->persist($passenger);
121 | $this->entityManager->flush();
122 |
123 | $jsonPassenger = $this->serializer->serialize(
124 | ['passenger' => $passenger]
125 | );
126 |
127 | // Add the passenger to the response body
128 | $response->getBody()->write($jsonPassenger);
129 |
130 | return $response->withStatus(StatusCodeInterface::STATUS_OK);
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/Controller/ReservationsController.php:
--------------------------------------------------------------------------------
1 | reservationRepository->countActiveReservationsByFlightNumber($number);
41 |
42 | $paginationMetadata = PaginationMetadataFactory::create($request, $totalItems);
43 |
44 | $reservations = $this->reservationRepository->findActiveReservationsByFlightNumber(
45 | $number,
46 | $paginationMetadata->page,
47 | $paginationMetadata->itemsPerPage,
48 | $request->getQueryParams()
49 | );
50 |
51 | // Serialize reservations under a reservations key
52 | $jsonReservations = $this->serializer->serialize(
53 | [
54 | 'reservations' => $reservations,
55 | 'links' => $paginationMetadata->links,
56 | 'meta' => $paginationMetadata->meta
57 | ]
58 | );
59 |
60 | // Write the reservations to the response body
61 | $response->getBody()->write($jsonReservations);
62 |
63 | // Return a cacheable response
64 | $response = $response->withHeader('Cache-Control', 'public, max-age=600');
65 |
66 | return $response;
67 | }
68 |
69 | public function store(Request $request, Response $response, string $number): Response
70 | {
71 | // Grab the post data and map to a flight
72 | $reservationJson = $request->getParsedBody();
73 |
74 | // Get the passenger and the flight
75 | $passenger = $this->entityManager->getRepository(Passenger::class)
76 | ->findOneBy(['reference' => $reservationJson['passengerReference']]);
77 |
78 | if (!$passenger) {
79 | throw new HttpNotFoundException($request, "Passenger {$reservationJson['passengerReference']} not found.");
80 | }
81 |
82 | // Fetch Flight entity
83 | $flight = $this->entityManager->getRepository(Flight::class)
84 | ->findOneBy(['number' => $number]);
85 |
86 | if (!$flight) {
87 | throw new HttpNotFoundException($request, "Flight {$reservationJson['flightNumber']} not found.");
88 | }
89 |
90 | $user = $request->getAttribute('user');
91 |
92 | // Create new Reservation
93 | $reservation = new Reservation();
94 | $reference = strstr(time() . $number, '-', true);
95 | $reservation->setReference($reference);
96 | $reservation->setFlight($flight);
97 | $reservation->setPassenger($passenger);
98 | $reservation->setSeatNumber($reservationJson['seatNumber']);
99 | $reservation->setTravelClass($reservationJson['travelClass']);
100 | $reservation->setOwnerId($user->getId());
101 |
102 | // Validate the reservation
103 | $this->validator->validate($reservation, $request);
104 |
105 | // Persist the new Reservation
106 | $this->entityManager->persist($reservation);
107 | $this->entityManager->flush();
108 |
109 | // Serialize the new reservation
110 | $jsonReservation = $this->serializer->serialize(
111 | ['reservation' => $reservation]
112 | );
113 |
114 | // Add the reservation to the response body
115 | $response->getBody()->write($jsonReservation);
116 |
117 | // Return the response
118 | return $response->withStatus(StatusCodeInterface::STATUS_CREATED);
119 | }
120 |
121 | public function show(Request $request, Response $response, string $reference): Response
122 | {
123 | // Find using repository
124 | $reservation = $this->reservationRepository->findArrayByReference($reference);
125 |
126 | // Exit if not found
127 | if (!$reservation) {
128 | throw new HttpNotFoundException($request, "Reservation $reference not found.");
129 | }
130 |
131 | // Serialize
132 | $jsonReservation = $this->serializer->serialize(
133 | ['reservation' => $reservation]
134 | );
135 |
136 | // Write to body
137 | $response->getBody()->write($jsonReservation);
138 |
139 | // Return a cacheable response
140 | return $response->withHeader('Cache-Control', 'public, max-age=600');
141 | }
142 |
143 | public function destroy(Request $request, Response $response, string $reference): Response
144 | {
145 | return $this->cancel($request, $response, $reference);
146 | }
147 |
148 | private function cancel(Request $request, Response $response, string $reference): Response
149 | {
150 | // Find the reservation by reference
151 | /** @var Reservation $reservation */
152 | $reservation = $this->reservationRepository
153 | ->findOneBy(['reference' => $reference]);
154 |
155 | // Handle not found
156 | if (!$reservation) {
157 | throw new HttpNotFoundException($request, "Reservation $reference not found.");
158 | }
159 |
160 | // Set cancelledAt
161 | $reservation->setCancelledAt(new \DateTimeImmutable());
162 |
163 | // Validate only cancellation fields
164 | $this->validator->validate($reservation, $request, [Reservation::CANCEL_GROUP]);
165 |
166 | // Persist changes
167 | $this->entityManager->persist($reservation);
168 | $this->entityManager->flush();
169 |
170 | // Return no content response
171 | return $response->withStatus(StatusCodeInterface::STATUS_NO_CONTENT);
172 | }
173 |
174 | public function update(Request $request, Response $response, string $reference): Response
175 | {
176 | // Retrieve the reservation
177 | $reservation = $this->reservationRepository->findByReference($reference);
178 |
179 | // Exit if not found
180 | if (!$reservation) {
181 | throw new HttpNotFoundException($request, "Reservation $reference not found");
182 | }
183 |
184 | // Grab the post data (body content)
185 | $reservationJson = $request->getBody()->getContents();
186 |
187 | // Deserialize into a Reservation entity object
188 | // (Only the fields eligible for update)
189 | $reservation = $this->serializer->deserialize(
190 | data: $reservationJson,
191 | type: Reservation::class,
192 | context: [
193 | AbstractNormalizer::OBJECT_TO_POPULATE => $reservation,
194 | AbstractNormalizer::IGNORED_ATTRIBUTES => [
195 | 'reference', 'flight', 'passenger', 'createdAt', 'cancelledAt'
196 | ]
197 | ]
198 | );
199 |
200 | $user = $request->getAttribute('user');
201 |
202 | if (!$this->accessControlManager->allows(
203 | AccessControlManager::UPDATE_RESERVATION,
204 | $user,
205 | $reservation)
206 | ) {
207 | throw new HttpForbiddenException($request, "You can not update this reservation");
208 | }
209 |
210 | // Validate
211 | $this->validator->validate($reservation, $request);
212 |
213 | // Persist
214 | $this->entityManager->persist($reservation);
215 | $this->entityManager->flush();
216 |
217 | // Serialize the updated reservation
218 | // (Only the fields we want in the response content)
219 | $jsonReservation = $this->serializer->serialize(
220 | data: ['reservation' => $reservation],
221 | context: [
222 | AbstractNormalizer::IGNORED_ATTRIBUTES => ['flight', 'passenger', 'cancelledAt']
223 | ]
224 | );
225 |
226 | // Add to the response body
227 | $response->getBody()->write($jsonReservation);
228 |
229 | // Send success (200) response
230 | return $response->withStatus(StatusCodeInterface::STATUS_OK);
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/src/Entity/Flight.php:
--------------------------------------------------------------------------------
1 | number;
46 | }
47 |
48 | public function setNumber(string $number): void
49 | {
50 | $this->number = $number;
51 | }
52 |
53 | public function getOrigin(): string
54 | {
55 | return $this->origin;
56 | }
57 |
58 | public function setOrigin(string $origin): void
59 | {
60 | $this->origin = $origin;
61 | }
62 |
63 | public function getDestination(): string
64 | {
65 | return $this->destination;
66 | }
67 |
68 | public function setDestination(string $destination): void
69 | {
70 | $this->destination = $destination;
71 | }
72 |
73 | public function getDepartureTime(): DateTimeImmutable
74 | {
75 | return $this->departureTime;
76 | }
77 |
78 | public function setDepartureTime(string|DateTimeImmutable $departureTime): void
79 | {
80 | if (is_string($departureTime)) {
81 | $departureTime = new DateTimeImmutable($departureTime);
82 | }
83 |
84 | $this->departureTime = $departureTime;
85 | }
86 |
87 | public function getArrivalTime(): DateTimeImmutable
88 | {
89 | return $this->arrivalTime;
90 | }
91 |
92 | public function setArrivalTime(string|DateTimeImmutable $arrivalTime): void
93 | {
94 | if (is_string($arrivalTime)) {
95 | $arrivalTime = new DateTimeImmutable($arrivalTime);
96 | }
97 |
98 | $this->arrivalTime = $arrivalTime;
99 | }
100 |
101 | public function getOwnerId(): string
102 | {
103 | return '';
104 | }
105 | }
--------------------------------------------------------------------------------
/src/Entity/Passenger.php:
--------------------------------------------------------------------------------
1 | reference;
54 | }
55 |
56 | public function setReference(string $reference): void
57 | {
58 | $this->reference = $reference;
59 | }
60 |
61 | public function getFirstNames(): string
62 | {
63 | return $this->firstNames;
64 | }
65 |
66 | public function setFirstNames(string $firstNames): void
67 | {
68 | $this->firstNames = $firstNames;
69 | }
70 |
71 | public function getLastName(): string
72 | {
73 | return $this->lastName;
74 | }
75 |
76 | public function setLastName(string $lastName): void
77 | {
78 | $this->lastName = $lastName;
79 | }
80 |
81 | public function getPassportNumber(): ?string
82 | {
83 | return $this->passportNumber;
84 | }
85 |
86 | public function setPassportNumber(?string $passportNumber): void
87 | {
88 | $this->passportNumber = $passportNumber;
89 | }
90 |
91 | public function getDateOfBirth(): string
92 | {
93 | return $this->dateOfBirth->format('Y-m-d');
94 | }
95 |
96 | public function setDateOfBirth(string|DateTimeImmutable $dateOfBirth): void
97 | {
98 | if (is_string($dateOfBirth)) {
99 | $dateOfBirth = new DateTimeImmutable($dateOfBirth);
100 | }
101 |
102 | $this->dateOfBirth = $dateOfBirth;
103 | }
104 |
105 | public function getNationality(): ?string
106 | {
107 | return $this->nationality;
108 | }
109 |
110 | public function setNationality(?string $nationality): void
111 | {
112 | $this->nationality = $nationality;
113 | }
114 |
115 | public function getCountryOfOrigin(): ?string
116 | {
117 | return $this->countryOfOrigin;
118 | }
119 |
120 | public function setCountryOfOrigin(?string $countryOfOrigin): void
121 | {
122 | $this->countryOfOrigin = $countryOfOrigin;
123 | }
124 |
125 | public function getOwnerId(): string
126 | {
127 | return '';
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/Entity/Reservation.php:
--------------------------------------------------------------------------------
1 | createdAt = new DateTimeImmutable();
54 | }
55 |
56 | public function getReference(): string
57 | {
58 | return $this->reference;
59 | }
60 |
61 | public function setReference(string $reference): void
62 | {
63 | $this->reference = $reference;
64 | }
65 |
66 | public function getFlight(): Flight
67 | {
68 | return $this->flight;
69 | }
70 |
71 | public function setFlight(Flight $flight): void
72 | {
73 | $this->flight = $flight;
74 | }
75 |
76 | public function getPassenger(): Passenger
77 | {
78 | return $this->passenger;
79 | }
80 |
81 | public function setPassenger(Passenger $passenger): void
82 | {
83 | $this->passenger = $passenger;
84 | }
85 |
86 | public function getSeatNumber(): string
87 | {
88 | return $this->seatNumber;
89 | }
90 |
91 | public function setSeatNumber(string $seatNumber): void
92 | {
93 | $this->seatNumber = $seatNumber;
94 | }
95 |
96 | public function getTravelClass(): string
97 | {
98 | return $this->travelClass;
99 | }
100 |
101 | public function setTravelClass(string $travelClass): void
102 | {
103 | $this->travelClass = $travelClass;
104 | }
105 |
106 | public function getCreatedAt(): DateTimeInterface
107 | {
108 | return $this->createdAt;
109 | }
110 |
111 | public function setCreatedAt(string|DateTimeImmutable $createdAt): void
112 | {
113 | if (is_string($createdAt)) {
114 | $createdAt = new DateTimeImmutable($createdAt);
115 | }
116 |
117 | $this->createdAt = $createdAt;
118 | }
119 |
120 | public function getCancelledAt(): ?DateTimeInterface
121 | {
122 | return $this->cancelledAt;
123 | }
124 |
125 | public function setCancelledAt(string|DateTimeImmutable $cancelledAt): void
126 | {
127 | if (is_string($cancelledAt)) {
128 | $cancelledAt = new DateTimeImmutable($cancelledAt);
129 | }
130 |
131 | $this->cancelledAt = $cancelledAt;
132 | }
133 |
134 | public function getOwnerId(): string
135 | {
136 | return $this->ownerId;
137 | }
138 |
139 | public function setOwnerId(string $ownerId): void
140 | {
141 | $this->ownerId = $ownerId;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/Entity/ResourceInterface.php:
--------------------------------------------------------------------------------
1 | validator->validate(value: $entity, groups: $groups);
22 |
23 | // Return if no errors
24 | if (count($errors) === 0) {
25 | return;
26 | }
27 |
28 | // Initialize $validationErrors array
29 | $validationErrors = [];
30 |
31 | // Loop errors
32 | foreach ($errors as $error) {
33 | // Add property and message keys to $validationErrors
34 | $validationErrors[] = [
35 | 'property' => $error->getPropertyPath(),
36 | 'message' => $error->getMessage()
37 | ];
38 | }
39 |
40 | // Create a ValidationException
41 | $validationException = new ValidationException($request);
42 |
43 | // Add errors to the ValidationException
44 | $validationException->setErrors($validationErrors);
45 |
46 | // Throw the exception
47 | throw $validationException;
48 | }
49 | }
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/Entity/User.php:
--------------------------------------------------------------------------------
1 | sub, $token->role ?? 'user');
18 | }
19 |
20 | public function getRole(): string
21 | {
22 | return $this->role;
23 | }
24 |
25 | public function getId(): string
26 | {
27 | return $this->id;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Http/Error/Exception/ExtensibleExceptionInterface.php:
--------------------------------------------------------------------------------
1 | errors;
23 | }
24 |
25 | public function setErrors(array $errors): self
26 | {
27 | $this->errors = $errors;
28 | return $this;
29 | }
30 |
31 | public function getExtensions(): array
32 | {
33 | return ['errors' => $this->errors];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Http/Error/HttpErrorHandler.php:
--------------------------------------------------------------------------------
1 | exception;
27 | $statusCode = 500;
28 | $problem = ProblemDetail::INTERNAL_SERVER_ERROR;
29 | $description = 'An internal error has occurred while processing your request.';
30 | $title = '500 Internal Server Error';
31 |
32 | if ($exception instanceof HttpException) {
33 | $statusCode = $exception->getCode();
34 | $description = $exception->getDescription();
35 | $title = $exception->getTitle();
36 |
37 | $problem = ProblemDetail::tryFrom($exception->getCode()) ?? ProblemDetail::BAD_REQUEST;
38 | }
39 |
40 | if (
41 | !($exception instanceof HttpException)
42 | && ($exception instanceof Throwable)
43 | && $this->displayErrorDetails
44 | ) {
45 | $description = $exception->getMessage();
46 | }
47 |
48 | $error = [
49 | 'type' => $problem->type(),
50 | 'title' => $title,
51 | 'detail' => $description,
52 | 'instance' => $this->request->getUri()->getPath(),
53 | ];
54 | # extensions (examples) - Use custom exceptions for these and array merge the extensions
55 | if ($exception instanceof ExtensibleExceptionInterface) {
56 | $error += $exception->getExtensions();
57 | }
58 |
59 | $payload = json_encode($error);
60 |
61 | $response = $this->responseFactory->createResponse($statusCode);
62 | $response->getBody()->write($payload);
63 | $response = $response->withHeader('Content-Type', ContentType::JSON_PROBLEM->value);
64 |
65 | return $response;
66 | }
67 | }
--------------------------------------------------------------------------------
/src/Http/Error/ProblemDetail.php:
--------------------------------------------------------------------------------
1 | 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1',
22 | self::UNAUTHORIZED => 'https://datatracker.ietf.org/doc/html/rfc7235#section-3.1',
23 | self::FORBIDDEN => 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.3',
24 | self::NOT_FOUND => 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4',
25 | self::METHOD_NOT_ALLOWED => 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.5',
26 | self::NOT_IMPLEMENTED => 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.2',
27 | self::INTERNAL_SERVER_ERROR => 'https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1',
28 | self::UNPROCESSABLE_CONTENT => 'https://www.rfc-editor.org/rfc/rfc9110.html#name-422-unprocessable-content'
29 | };
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Cache/ContentCacheMiddleware.php:
--------------------------------------------------------------------------------
1 | getQueryParams();
23 |
24 | $shouldCache = empty(array_diff(array_keys($filters), ['page']));
25 |
26 | $cacheKey = str_replace('/', '', $request->getUri()->getPath()) . ".page=" . ($filters['page'] ?? 1);
27 |
28 | if ($shouldCache) {
29 | $cacheItem = $this->cache->getItem($cacheKey);
30 |
31 | if ($cacheItem->isHit()) {
32 | $response = new Response();
33 | $response->getBody()->write($cacheItem->get());
34 | return $response->withHeader('Cache-Control', 'public, max-age=600');
35 | }
36 | }
37 |
38 | $response = $handler->handle($request);
39 |
40 | $response->getBody()->rewind();
41 | $content = $response->getBody()->getContents();
42 |
43 | // caching
44 | if ($shouldCache) {
45 | $cacheItem->set($content);
46 | $cacheItem->expiresAfter(600);
47 | $this->cache->save($cacheItem);
48 | }
49 |
50 | return $response;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Cache/HttpCacheMiddleware.php:
--------------------------------------------------------------------------------
1 | handle($request);
25 |
26 | if (!empty($this->cacheControl)) {
27 | $response = $response->withHeader('Cache-Control', implode(', ', $this->cacheControl));
28 | }
29 |
30 | if ($this->expires) {
31 | $response = $response->withHeader('Expires', gmdate('D, d M Y H:i:s', time() + $this->expires) . ' GMT');
32 | }
33 |
34 | if (!empty($this->vary)) {
35 | $response = $response->withHeader('Vary', implode(', ', $this->vary));
36 | }
37 |
38 | if ($this->includeContentLength) {
39 | $contentLength = $response->getBody()->getSize();
40 | $response = $response->withHeader('Content-Length', (string) $contentLength);
41 | }
42 |
43 | return $response;
44 | }
45 | }
--------------------------------------------------------------------------------
/src/Http/Middleware/ContentNegotiation/ContentNegotiationInterface.php:
--------------------------------------------------------------------------------
1 | 'json',
19 | self::HTML => 'html',
20 | self::XML => 'xml',
21 | self::CSV => 'csv',
22 | };
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Http/Middleware/ContentNegotiation/ContentTypeMiddleware.php:
--------------------------------------------------------------------------------
1 | contentNegotiator->negotiate($request);
22 |
23 | // Handle the request..returns a Response
24 | $response = $handler->handle($request);
25 |
26 | // Return the response
27 | return $response->withHeader(
28 | 'Content-Type',
29 | $request->getAttribute('content-type')->value
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Http/Middleware/ContentNegotiation/ContentTypeNegotiator.php:
--------------------------------------------------------------------------------
1 | getHeaderLine('Accept');
20 |
21 | $requestedFormats = explode(',', $accept);
22 |
23 | foreach ($requestedFormats as $requestedFormat) {
24 | if ($format = ContentType::tryFrom($requestedFormat)) {
25 | break;
26 | }
27 | }
28 |
29 | $contentType = ($format ?? ContentType::JSON);
30 |
31 | // Set format on serializer
32 | $this->serializer->setFormat($contentType->format());
33 |
34 | return $request->withAttribute('content-type', $contentType);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Deprecations/SunsetHeaderMiddleware.php:
--------------------------------------------------------------------------------
1 | handle($request);
22 |
23 | return $response->withHeader('Sunset', sprintf('date="%s"', $this->date));
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Http/Middleware/MiddlewareRegistrar.php:
--------------------------------------------------------------------------------
1 | registerCustomMiddleware();
31 | $this->registerDefaultMiddleware();
32 | $this->addErrorMiddleware();
33 | }
34 |
35 | private function registerCustomMiddleware(): void
36 | {
37 | $app = $this->app;
38 | $container = $app->getContainer();
39 |
40 | $serializer = $container->get(Serializer::class);
41 | // .. register custom middleware here
42 | $app->add(new ContentTypeMiddleware(new ContentTypeNegotiator($serializer)));
43 | $app->add(new MaintenanceModeMiddleware($container->get('maintenance_mode')));
44 | }
45 |
46 | private function registerDefaultMiddleware(): void
47 | {
48 | $app = $this->app;
49 |
50 | $app->addBodyParsingMiddleware();
51 | $app->addRoutingMiddleware();
52 | $app->add(new TrailingSlash(false));
53 | }
54 |
55 | private function addErrorMiddleware(): void
56 | {
57 | $logger = $this->app->getContainer()->get(LoggerInterface::class);
58 | $errorMiddleware = $this->app->addErrorMiddleware(true, true, true, $logger);
59 | $callableResolver = $this->app->getCallableResolver();
60 | $responseFactory = $this->app->getResponseFactory();
61 | $errorHandler = new HttpErrorHandler($callableResolver, $responseFactory, $logger);
62 | $errorMiddleware->setDefaultErrorHandler($errorHandler);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Security/JwtAuthenticationMiddleware.php:
--------------------------------------------------------------------------------
1 | getHeaderLine('Authorization');
26 |
27 | // Error if Bearer token not present
28 | if (!preg_match('/Bearer\s+(\S+)/', $header, $matches)) {
29 | throw new HttpUnauthorizedException($request, "Unable to authenticate");
30 | }
31 |
32 | try {
33 | $jwt = $matches[1];
34 |
35 | // Authenticate jwt
36 | $payload = $this->tokenAuthenticator->authenticate($jwt);
37 |
38 | } catch (\Exception $exception) {
39 | throw new HttpUnauthorizedException($request, "Unable to authenticate");
40 | }
41 |
42 | $user = User::createFromToken($payload);
43 |
44 | $request = $request->withAttribute('user', $user);
45 |
46 | return $handler->handle($request);
47 | }
48 | }
--------------------------------------------------------------------------------
/src/Http/Middleware/Security/PermissionsMiddleware.php:
--------------------------------------------------------------------------------
1 | getAttribute('user');
28 | assert($user instanceof User);
29 |
30 | if (!$this->accessControlManager->hasPermission($user->getRole(), $this->permission)) {
31 | throw new HttpForbiddenException($request, "You don't have the required permissions");
32 | }
33 |
34 | return $handler->handle($request);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Utility/MaintenanceModeMiddleware.php:
--------------------------------------------------------------------------------
1 | isMaintenanceMode) {
23 | return $handler->handle($request);
24 | }
25 |
26 | $response = new Response(StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE);
27 |
28 | $response->getBody()->write('The API is currently down for maintenance');
29 | $response = $response->withHeader('Retry-After', 3600);
30 |
31 | return $response;
32 | }
33 | }
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/Pagination/PaginationMetadata.php:
--------------------------------------------------------------------------------
1 | getQueryParams();
14 | $page = (int) ($queryParams['page'] ?? 1);
15 | $itemsPerPage = (int) ($queryParams['itemsPerPage'] ?? PaginationMetadata::ITEMS_PER_PAGE);
16 |
17 | $totalPages = (int) ceil($totalItems / $itemsPerPage);
18 | $path = $request->getUri()->getPath();
19 |
20 | $links = [
21 | 'self' => "$path?page=$page&itemsPerPage=$itemsPerPage",
22 | 'first' => "$path?page=1&itemsPerPage=$itemsPerPage",
23 | 'last' => "$path?page=$totalPages&itemsPerPage=$itemsPerPage",
24 | 'prev' => $page > 1 ? "$path?page=" . ($page - 1) . "&itemsPerPage=$itemsPerPage" : null,
25 | 'next' => $page < $totalPages ? "$path?page=" . ($page + 1) . "&itemsPerPage=$itemsPerPage" : null
26 | ];
27 |
28 | $meta = [
29 | 'totalItems' => $totalItems,
30 | 'totalPages' => $totalPages,
31 | 'currentPage' => $page,
32 | 'itemsPerPage' => $itemsPerPage
33 | ];
34 |
35 | return new PaginationMetadata($page, $itemsPerPage, $links, $meta);
36 | }
37 | }
--------------------------------------------------------------------------------
/src/Repository/FlightRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder($tableName);
22 |
23 | $this->applyFilters($qb, $filters, $tableName);
24 |
25 | $sort = $filters['sort'] ?? null;
26 |
27 | Sort::apply(
28 | $sort,
29 | $qb,
30 | $tableName,
31 | ['departureTime', 'origin', 'destination']
32 | );
33 |
34 | $qb->setFirstResult(($page - 1) * $limit)
35 | ->setMaxResults($limit);
36 |
37 | return $qb->getQuery()->getResult();
38 | }
39 |
40 | public function countFilteredFlights(array $filters = []): int
41 | {
42 | $tableName = 'flights';
43 |
44 | $qb = $this->createQueryBuilder($tableName)
45 | ->select("count($tableName.id)");
46 |
47 | $this->applyFilters($qb, $filters, $tableName);
48 |
49 | return (int) $qb->getQuery()->getSingleScalarResult();
50 | }
51 |
52 | private function applyFilters(
53 | QueryBuilder $qb,
54 | array $filters,
55 | string $tableName
56 | ): void {
57 | // Filter by origin if provided
58 | if (isset($filters['origin'])) {
59 | $qb->andWhere("$tableName.origin = :origin")
60 | ->setParameter('origin', $filters['origin']);
61 | }
62 |
63 | // Filter by destination if provided
64 | if (isset($filters['destination'])) {
65 | $qb->andWhere("$tableName.destination = :destination")
66 | ->setParameter('destination', $filters['destination']);
67 | }
68 |
69 | // Filter by departure date if provided
70 | if (isset($filters['departureDate'])) {
71 | $qb->andWhere("SUBSTRING($tableName.departureTime, 1, 10) = :departureDate")
72 | ->setParameter('departureDate', $filters['departureDate']);
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Repository/QueryUtils/Sort.php:
--------------------------------------------------------------------------------
1 | addOrderBy("$tableName.$sortField", $sortOrder);
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Repository/ReservationRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder($tableName);
22 |
23 | $qb->select("$tableName.reference, $tableName.seatNumber, $tableName.travelClass, $tableName.createdAt, f.number AS flightNumber, p.reference AS passengerReference")
24 | ->leftJoin("$tableName.flight", "f")
25 | ->leftJoin("$tableName.passenger", "p")
26 | ->where("$tableName.cancelledAt IS NULL")
27 | ->andWhere('f.number = :flightNumber') // Filter by flight number
28 | ->setParameter('flightNumber', $flightNumber); // Bind the flight number parameter
29 |
30 | Sort::apply(
31 | $filters['sort'] ?? null,
32 | $qb,
33 | $tableName,
34 | ['createdAt', 'travelClass', 'seatNumber']
35 | );
36 |
37 | $qb->setFirstResult(($page - 1) * $limit) // Calculate offset
38 | ->setMaxResults($limit); // Set limit
39 |
40 | $query = $qb->getQuery();
41 |
42 | return $query->getResult();
43 | }
44 |
45 | public function countActiveReservationsByFlightNumber(string $flightNumber): int
46 | {
47 | $count = $this->createQueryBuilder('r')
48 | ->select('count(r.id)')
49 | ->leftJoin('r.flight', 'f')
50 | ->where('r.cancelledAt IS NULL')
51 | ->andWhere('f.number = :flightNumber')
52 | ->setParameter('flightNumber', $flightNumber)
53 | ->getQuery()
54 | ->getSingleScalarResult();
55 |
56 | return (int) $count;
57 | }
58 |
59 | public function findByReference(string $reference): ?Reservation
60 | {
61 | return $this->createQueryBuilder('r')
62 | ->where('r.reference = :reference') // Filter by reservation reference
63 | ->setParameter('reference', $reference) // Bind the reservation reference parameter
64 | ->getQuery()
65 | ->setMaxResults(1)
66 | ->getOneOrNullResult();
67 | }
68 |
69 | public function findArrayByReference(string $reference): ?array
70 | {
71 | return $this->createQueryBuilder('r')
72 | ->select('r.reference, r.seatNumber, r.travelClass, r.createdAt, f.number AS flightNumber, p.reference AS passengerReference')
73 | ->leftJoin('r.flight', 'f')
74 | ->leftJoin('r.passenger', 'p')
75 | ->where('r.reference = :reference') // Filter by reservation reference
76 | ->setParameter('reference', $reference) // Bind the reservation reference parameter
77 | ->getQuery()
78 | ->setMaxResults(1)
79 | ->getOneOrNullResult();
80 | }
81 | }
--------------------------------------------------------------------------------
/src/Security/AccessControlManager.php:
--------------------------------------------------------------------------------
1 | [],
22 | self::ROLE_PARTNER => [self::UPDATE_RESERVATION, self::CREATE_RESERVATION]
23 | ];
24 |
25 | public function __construct()
26 | {
27 | $this->rolePermissions['admin'] = array_merge(
28 | $this->rolePermissions['admin'],
29 | $this->rolePermissions['partner']
30 | );
31 | }
32 |
33 | public function hasPermission(string $role, string $permission): bool
34 | {
35 | return in_array($permission, $this->rolePermissions[$role]);
36 | }
37 |
38 |
39 | public function allows(string $permission, User $user, ResourceInterface $resource): bool
40 | {
41 | // Is an admin
42 | if ($user->getRole() === self::ROLE_ADMIN) {
43 | return true;
44 | }
45 |
46 | // Has permission + is creator of the resource
47 | return $this->hasPermission($user->getRole(), $permission)
48 | && $user->getId() === $resource->getOwnerId();
49 | }
50 | }
--------------------------------------------------------------------------------
/src/Security/TokenAuthenticator.php:
--------------------------------------------------------------------------------
1 | selectKey($jwt, $jwks);
20 |
21 | return JWT::decode($jwt, new Key($key, 'RS256'));
22 | }
23 |
24 | private function selectKey(string $jwt, $jwks): string
25 | {
26 | $header = explode('.', $jwt)[0];
27 | $header = base64_decode($header);
28 | $kid = json_decode($header, true)['kid'] ?? null;
29 |
30 | // Here's what you should really do
31 | // Find the key in jwks using this $kid
32 | // Decode the base64 URL-encoded `n` and `e`.
33 | // Construct an RSA public key using the decoded `n` and `e`.
34 | // Return the RSA public key to verify the signature of the JWT.
35 |
36 | // But we're just going to cheat and return the key
37 | return file_get_contents('../public.pem');
38 | }
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/src/Serializer/Serializer.php:
--------------------------------------------------------------------------------
1 | serializer->serialize($data, $format ?? $this->format, $context);
20 | }
21 |
22 | public function deserialize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
23 | {
24 | return $this->serializer->deserialize($data ,$type, $format ?? $this->format, $context);
25 | }
26 |
27 | public function setFormat(string $format): void
28 | {
29 | $this->format = $format;
30 | }
31 | }
--------------------------------------------------------------------------------