├── .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 | ![DB connection creds](doc/flights-api.png "Connecting to the database") 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 | 15 | 16 |

Deprecated

17 | 20 | 21 |

Removed

22 | 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 | } --------------------------------------------------------------------------------