├── .env.dist
├── .htaccess
├── composer.json
├── example-web-app-REST-API-architecture.svg
├── index.php
├── license.md
├── readme.md
└── src
├── AllowCors.php
├── Dal
├── FoodItemDal.php
├── TokenKeyDal.php
└── UserDal.php
├── Entity
├── Entitable.php
├── Item.php
└── User.php
├── Route
├── Exception
│ └── NotFoundException.php
├── Http.php
├── food-item.routes.php
├── not-found.routes.php
├── routes.php
└── user.routes.php
├── Service
├── Exception
│ ├── CannotLoginUserException.php
│ ├── CredentialsInvalidException.php
│ └── EmailExistsException.php
├── FoodItem.php
├── SecretKey.php
└── User.php
├── Validation
├── Exception
│ └── InvalidValidationException.php
└── UserValidation.php
├── config
├── config.inc.php
└── database.inc.php
└── helpers
├── headers.inc.php
└── misc.inc.php
/.env.dist:
--------------------------------------------------------------------------------
1 | # API URL
2 | APP_URL="http://localhost:8080"
3 |
4 | # JSON Web Token
5 | JWT_TOKEN_EXPIRATION="86400" # in seconds
6 | JWT_ALGO_ENCRYPTION="HS512"
7 |
8 | # Database details
9 | DB_HOST=""
10 | DB_NAME=""
11 | DB_USER=""
12 | DB_PASS=""
13 |
--------------------------------------------------------------------------------
/.htaccess:
--------------------------------------------------------------------------------
1 | # DirectoryIndex index.php
2 |
3 |
4 |
5 | # Disable Directory Browsing
6 | Options -MultiViews -Indexes
7 |
8 | Options +FollowSymLinks
9 |
10 | # Indicate to Apache to enable URLs rewrite
11 | RewriteEngine On
12 |
13 | # Uncomment below "#RewriteBase /"if installed in a folder, and then, add the folder name after the slash "/"
14 | # RewriteBase /
15 |
16 | # This is where the magic happens
17 | # Rewrite the requests to index.php with correct GET params
18 | RewriteRule ^([^/]+)/?([^/]+)?/?([^/]+)?/?$ index.php?resource=$1&action=$2&id=$3 [QSA,L]
19 |
20 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pierrehenry/api-simple-menu",
3 | "description": "A simple RESTful API for a Udemy course",
4 | "homepage": "https://www.udemy.com/course/build-modern-php-api/",
5 | "autoload": {
6 | "psr-4": {
7 | "PH7\\ApiSimpleMenu\\": "src/"
8 | },
9 | "files": [
10 | "src/helpers/headers.inc.php",
11 | "src/helpers/misc.inc.php",
12 | "src/config/config.inc.php",
13 | "src/config/database.inc.php",
14 | "src/Route/routes.php"
15 | ]
16 | },
17 | "authors": [
18 | {
19 | "name": "Pierre-Henry Soria",
20 | "email": "hi@ph7.me"
21 | }
22 | ],
23 |
24 | "config": {
25 | "platform": {
26 | "php": "8.2.0"
27 | }
28 | },
29 | "require": {
30 | "php": ">=8.2.0",
31 | "firebase/php-jwt": "^6.4",
32 | "respect/validation": "^2.2",
33 | "ph-7/php-http-response-header": "^1.0.2",
34 | "ph-7/just-http-status-codes": "^1.1",
35 | "ramsey/uuid": "^4.7",
36 | "gabordemooij/redbean": "^5.7",
37 | "vlucas/phpdotenv": "^5.5",
38 | "filp/whoops": "^2.15"
39 | },
40 | "require-dev": {
41 | "phpunit/phpunit": "^10.2"
42 | },
43 | "scripts": {
44 | "test": "vendor/bin/phpunit"
45 | },
46 | "license": "MIT"
47 | }
48 |
--------------------------------------------------------------------------------
/example-web-app-REST-API-architecture.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.php:
--------------------------------------------------------------------------------
1 | pushHandler(new WhoopsJsonResponseHandler);
12 | $whoops->register();
13 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | ## MIT License
2 |
3 | **Copyright (c) 2023 Pierre-Henry Soria**
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Build a PHP RESTful API
2 |
3 | This is the source code of my Udemy course **[Build a Modern REST API with PHP 8.2](https://www.udemy.com/course/build-modern-php-api/)** 🚀
4 |
5 | 
6 |
7 |
8 | ## The course
9 |
10 | It is indispensable to follow [Build from Scratch a Modern REST API](https://www.udemy.com/course/build-modern-php-api/) in order to understand and setup properly this project.
11 |
12 |
13 | ## Requirements
14 |
15 | 1. Enroll [Build from Scratch a Modern REST API with PHP 8](https://www.udemy.com/course/build-modern-php-api/) course.
16 | 2. [PHP v8.2](https://www.php.net/releases/8.2/en.php) or newer.
17 |
18 |
19 | ## Quick Setup
20 |
21 | * Ensure [Composer](https://getcomposer.org) is setup on your machine. Then, run `composer install`
22 | * Run the in-built PHP server `php -S localhost:8080`
23 |
24 |
25 | ## Author
26 |
27 | [](https://pierrehenry.be "Pierre-Henry Soria, Software AI Engineer")
28 |
29 | **[Pierre-Henry Soria](https://ph7.me)**. A truly super passionate and enthusiastic software engineer! 😊 Also, a **true cheese** 🧀, **dark chocolate**, and **espresso lover**! ☕️
30 |
31 |
32 | ## License
33 |
34 | This source code is distributed under the open-source [MIT license](https://opensource.org/licenses/MIT).
35 |
--------------------------------------------------------------------------------
/src/AllowCors.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu;
11 |
12 | class AllowCors
13 | {
14 | private const ALLOW_CORS_ORIGIN_KEY = 'Access-Control-Allow-Origin';
15 | private const ALLOW_CORS_METHOD_KEY = 'Access-Control-Allow-Methods';
16 |
17 | private const ALLOW_CORS_ORIGIN_VALUE = '*';
18 | private const ALLOW_CORS_METHODS_VALUE = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
19 |
20 | /**
21 | * Initialize the Cross-Origin Resource Sharing (CORS) headers.
22 | */
23 | public function init(): void
24 | {
25 | $this->set(self::ALLOW_CORS_ORIGIN_KEY, self::ALLOW_CORS_ORIGIN_VALUE);
26 | $this->set(self::ALLOW_CORS_METHOD_KEY, self::ALLOW_CORS_METHODS_VALUE);
27 | }
28 |
29 | private function set(string $key, string $value): void
30 | {
31 | header($key . ':' . $value);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Dal/FoodItemDal.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Dal;
11 |
12 | use PH7\ApiSimpleMenu\Entity\Item as ItemEntity;
13 | use RedBeanPHP\R;
14 |
15 | final class FoodItemDal
16 | {
17 | public const TABLE_NAME = 'fooditems'; // Cannot have underscore. Use one word
18 |
19 | public static function get(string $itemUuid): ItemEntity
20 | {
21 | $bindings = ['itemUuid' => $itemUuid];
22 | $itemBean = R::findOne(self::TABLE_NAME, 'item_uuid = :itemUuid', $bindings);
23 |
24 | return (new ItemEntity())->unserialize($itemBean?->export());
25 | }
26 |
27 | public static function getAll(): array
28 | {
29 | $itemsBean = R::findAll(self::TABLE_NAME);
30 |
31 | $areAnyItems = $itemsBean && count($itemsBean);
32 |
33 | if (!$areAnyItems) {
34 | // if no items found, return empty array
35 | return [];
36 | }
37 |
38 | return array_map(
39 | function (object $itemBean): array {
40 | $itemEntity = (new ItemEntity())->unserialize($itemBean?->export());
41 |
42 | // Select the fields we want to export and give back to the client
43 | return [
44 | 'foodUuid' => $itemEntity->getItemUuid(),
45 | 'name' => $itemEntity->getName(),
46 | 'price' => $itemEntity->getPrice(),
47 | 'available' => $itemEntity->getAvailable()
48 | ];
49 | }, $itemsBean);
50 | }
51 |
52 | public static function insertDefaultItem(ItemEntity $itemEntity): int|string
53 | {
54 | $itemBan = R::dispense(self::TABLE_NAME);
55 |
56 | $itemBan->item_uuid = $itemEntity->getItemUuid();
57 | $itemBan->name = $itemEntity->getName();
58 | $itemBan->price = $itemEntity->getPrice();
59 | $itemBan->available = $itemEntity->getAvailable();
60 |
61 | // return the increment entry ID
62 | return R::store($itemBan);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Dal/TokenKeyDal.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Dal;
11 |
12 | use RedBeanPHP\R;
13 |
14 | final class TokenKeyDal
15 | {
16 | public const TABLE_NAME = 'secretkeys';
17 |
18 | public static function saveSecretKey(string $jwtKey): void
19 | {
20 | $tokenBean = R::dispense(self::TABLE_NAME);
21 | $tokenBean->secretKey = $jwtKey;
22 |
23 | R::store($tokenBean);
24 |
25 | // close connection with database
26 | R::close();
27 | }
28 |
29 | public static function getSecretKey(): ?string
30 | {
31 | $tokenKeyBean = R::load(self::TABLE_NAME, 1);
32 |
33 | return $tokenKeyBean?->secretKey;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Dal/UserDal.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Dal;
11 |
12 | use PH7\ApiSimpleMenu\Entity\User as UserEntity;
13 | use RedBeanPHP\R;
14 | use RedBeanPHP\RedException\SQL;
15 |
16 | final class UserDal
17 | {
18 | public const TABLE_NAME = 'users';
19 |
20 | public static function create(UserEntity $userEntity): string|false
21 | {
22 | $userBean = R::dispense(self::TABLE_NAME);
23 | $userBean->user_uuid = $userEntity->getUserUuid();
24 | $userBean->first_name = $userEntity->getFirstName();
25 | $userBean->last_name = $userEntity->getLastName();
26 | $userBean->email = $userEntity->getEmail();
27 | $userBean->phone = $userEntity->getPhone();
28 | $userBean->password = $userEntity->getPassword();
29 | $userBean->created_date = $userEntity->getCreationDate();
30 |
31 | try {
32 | $redBeanIncrementId = R::store($userBean);
33 | } catch (SQL) { // since PHP 8, we can omit the caught variable (e.g. SQL $e)
34 | return false;
35 | } finally {
36 | R::close();
37 | }
38 |
39 | // Retrieve the user we just created for accessing to `user_uuid` column
40 | $userBean = R::load(self::TABLE_NAME, $redBeanIncrementId);
41 |
42 | // Return user UUID (UUID is a string datatype)
43 | return $userBean->user_uuid;
44 | }
45 |
46 | public static function update(string $userUuid, UserEntity $userEntity): int|string|false
47 | {
48 | $userBean = R::findOne(self::TABLE_NAME, 'user_uuid = :userUuid', ['userUuid' => $userUuid]);
49 |
50 | // If the user exists, update it
51 | if ($userBean) {
52 | $firstName = $userEntity->getFirstName();
53 | $lastName = $userEntity->getLastName();
54 | $phone = $userEntity->getPhone();
55 |
56 | if ($firstName) {
57 | $userBean->firstName = $firstName;
58 | }
59 |
60 | if ($lastName) {
61 | $userBean->lastName = $lastName;
62 | }
63 |
64 | if ($phone) {
65 | $userBean->phone = $phone;
66 | }
67 |
68 | // attempt to save the user
69 | try {
70 | return R::store($userBean); // returns the user ID
71 | } catch (SQL) { // PHP >=8.0 allows to omit the caught variable (e.g. SQL $e)
72 | return false;
73 | } finally {
74 | R::close();
75 | }
76 | }
77 |
78 | // Return false when the requested user isn't found
79 | return false;
80 | }
81 |
82 | public static function getById(string $userUuid): UserEntity
83 | {
84 | $bindings = ['userUuid' => $userUuid];
85 | $userBean = R::findOne(self::TABLE_NAME, 'user_uuid = :userUuid ', $bindings);
86 |
87 | return (new UserEntity())->unserialize($userBean?->export());
88 | }
89 |
90 | public static function getByEmail(string $email): UserEntity
91 | {
92 | $bindings = ['email' => $email];
93 |
94 | $userBean = R::findOne(self::TABLE_NAME, 'email = :email', $bindings);
95 |
96 | return (new UserEntity())->unserialize($userBean?->export());
97 | }
98 |
99 | /**
100 | * @throws \RedBeanPHP\RedException\SQL
101 | */
102 | public static function setToken(string $jwtToken, string $userUuid): void
103 | {
104 | $bindings = ['userUuid' => $userUuid];
105 | $userBean = R::findOne(self::TABLE_NAME, 'user_uuid = :userUuid', $bindings);
106 |
107 | $userBean->session_token = $jwtToken;
108 | $userBean->last_session_time = time();
109 |
110 | R::store($userBean);
111 |
112 | R::close();
113 | }
114 |
115 | public static function getAll(): ?array
116 | {
117 | $usersBean = R::findAll(self::TABLE_NAME);
118 | $areAnyUsers = $usersBean && count($usersBean);
119 |
120 | if (!$areAnyUsers) {
121 | return []; // guard clause approach
122 | }
123 |
124 | return array_map(function (object $userBean): array
125 | {
126 | $userEntity = (new UserEntity())->unserialize($userBean?->export());
127 | // Retrieve the User entity fields we want to expose to the client
128 | return [
129 | 'userUuid' => $userEntity->getUserUuid(),
130 | 'first' => $userEntity->getFirstName(),
131 | 'last' => $userEntity->getLastName(),
132 | 'email' => $userEntity->getEmail(),
133 | 'phone' => $userEntity->getPhone(),
134 | 'creationDate' => $userEntity->getCreationDate()
135 | ];
136 | }, $usersBean);
137 | }
138 |
139 | public static function remove(string $userUuid): bool
140 | {
141 | $userBean = R::findOne(self::TABLE_NAME, 'user_uuid = :userUuid', ['userUuid' => $userUuid]);
142 |
143 | if ($userBean) {
144 | return (bool)R::trash($userBean);
145 | }
146 |
147 | return false;
148 | }
149 |
150 | public static function doesEmailExist(string $email): bool
151 | {
152 | // If R::findOne doesn't find any rows, it returns NULL (meaning, the email address doesn't exist)
153 | return R::findOne(self::TABLE_NAME, 'email = :email', ['email' => $email]) !== null;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/Entity/Entitable.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Entity;
11 |
12 | interface Entitable
13 | {
14 | public function unserialize(?array $data): self;
15 |
16 | public function setSequentialId(int $sequentialId): self;
17 |
18 | public function getSequentialId(): int;
19 | }
20 |
--------------------------------------------------------------------------------
/src/Entity/Item.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Entity;
11 |
12 | class Item implements Entitable
13 | {
14 | private int $sequentialId;
15 |
16 | private ?string $itemUuid = null;
17 |
18 | private string $name;
19 |
20 | private float $price;
21 |
22 | private bool $available;
23 |
24 | public function setSequentialId(int $sequentialId): self
25 | {
26 | $this->sequentialId = $sequentialId;
27 |
28 | return $this;
29 | }
30 |
31 | public function getSequentialId(): int
32 | {
33 | return $this->sequentialId;
34 | }
35 |
36 | public function setItemUuid(string $itemUuid): self
37 | {
38 | $this->itemUuid = $itemUuid;
39 |
40 | return $this;
41 | }
42 |
43 | public function getItemUuid(): ?string
44 | {
45 | return $this->itemUuid;
46 | }
47 |
48 | public function setName(string $name): self
49 | {
50 | $this->name = $name;
51 |
52 | return $this;
53 | }
54 |
55 | public function getName(): string
56 | {
57 | return $this->name;
58 | }
59 |
60 | public function setPrice(float $price): self
61 | {
62 | $this->price = $price;
63 |
64 | return $this;
65 | }
66 |
67 | public function getPrice(): float
68 | {
69 | return $this->price;
70 | }
71 |
72 | public function setAvailable(bool $available): self
73 | {
74 | $this->available = $available;
75 |
76 | return $this;
77 | }
78 |
79 | public function getAvailable(): bool
80 | {
81 | return $this->available;
82 | }
83 |
84 | public function unserialize(?array $data): self
85 | {
86 | if (!empty($data['id'])) {
87 | $sequentialId = (int)$data['id'];
88 | $this->setSequentialId($sequentialId);
89 | }
90 |
91 | if (!empty($data['item_uuid'])) {
92 | $this->setItemUuid($data['item_uuid']);
93 | }
94 |
95 | if (!empty($data['name'])) {
96 | $this->setName($data['name']);
97 | }
98 |
99 | if (!empty($data['price'])) {
100 | $price = (float)$data['price'];
101 | $this->setPrice($price);
102 | }
103 |
104 | if (!empty($data['available'])) {
105 | $isAvailable = (bool)$data['available'];
106 | $this->setAvailable($isAvailable);
107 | }
108 |
109 | return $this;
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/Entity/User.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Entity;
11 |
12 | class User implements Entitable
13 | {
14 | private int $sequentialId;
15 | private ?string $userUuid = null;
16 |
17 | private ?string $firstName = null;
18 |
19 | private ?string $lastName = null;
20 |
21 | private ?string $email = null;
22 |
23 | private ?string $phone = null;
24 |
25 | private string $password;
26 |
27 | private ?string $creationDate = null;
28 |
29 | public function setSequentialId(int $sequentialId): self
30 | {
31 | $this->sequentialId = $sequentialId;
32 |
33 | return $this;
34 | }
35 |
36 | public function getSequentialId(): int
37 | {
38 | return $this->sequentialId;
39 | }
40 |
41 | public function setUserUuid(string $userUuid) :self
42 | {
43 | $this->userUuid = $userUuid;
44 |
45 | return $this;
46 | }
47 |
48 | public function getUserUuid(): ?string
49 | {
50 | return $this->userUuid;
51 | }
52 |
53 | public function setFirstName(string $firstName): self
54 | {
55 | $this->firstName = $firstName;
56 |
57 | return $this;
58 | }
59 |
60 | public function getFirstName(): ?string
61 | {
62 | return $this->firstName;
63 | }
64 |
65 | public function setLastName(string $lastName): self
66 | {
67 | $this->lastName = $lastName;
68 |
69 | return $this;
70 | }
71 |
72 | public function getLastName(): ?string
73 | {
74 | return $this->lastName;
75 | }
76 |
77 | public function setEmail(string $email): self
78 | {
79 | $this->email = $email;
80 |
81 | return $this;
82 | }
83 |
84 | public function getEmail(): ?string
85 | {
86 | return $this->email;
87 | }
88 |
89 | public function setPhone(string $phone): self
90 | {
91 | $this->phone = $phone;
92 |
93 | return $this;
94 | }
95 |
96 | public function getPhone(): ?string
97 | {
98 | return $this->phone;
99 | }
100 |
101 | public function setPassword(string $password): self
102 | {
103 | $this->password = $password;
104 |
105 | return $this;
106 | }
107 |
108 | public function getPassword(): string
109 | {
110 | return $this->password;
111 | }
112 |
113 | public function setCreationDate(string $creationDate): self
114 | {
115 | $this->creationDate = $creationDate;
116 |
117 | return $this;
118 | }
119 |
120 | public function getCreationDate(): ?string
121 | {
122 | return $this->creationDate;
123 | }
124 |
125 | public function unserialize(?array $data): self
126 | {
127 | if (!empty($data['id'])) {
128 | $sequentialId = (int)$data['id'];
129 | $this->setSequentialId($sequentialId);
130 | }
131 |
132 | if (!empty($data['user_uuid'])) {
133 | $this->setUserUuid($data['user_uuid']);
134 | }
135 |
136 | if (!empty($data['first_name'])) {
137 | $this->setFirstName($data['first_name']);
138 | }
139 |
140 | if (!empty($data['last_name'])) {
141 | $this->setLastName($data['last_name']);
142 | }
143 |
144 | if (!empty($data['email'])) {
145 | $this->setEmail($data['email']);
146 | }
147 |
148 | if (!empty($data['phone'])) {
149 | $this->setPhone($data['phone']);
150 | }
151 |
152 | if (!empty($data['password'])) {
153 | $this->setPassword($data['password']);
154 | }
155 |
156 | if (!empty($data['created_date'])) {
157 | $this->setCreationDate($data['created_date']);
158 | }
159 |
160 | return $this;
161 | }
162 |
163 | // public function serialize(): array
164 | // {
165 | // // optional. we could also only return the properties we want to make it safe
166 | // // (not all properties should indeed be returned)
167 | // return get_object_vars($this);
168 | // }
169 | }
170 |
--------------------------------------------------------------------------------
/src/Route/Exception/NotFoundException.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Route\Exception;
9 |
10 | use RuntimeException;
11 |
12 | class NotFoundException extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Route/Http.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Route;
11 |
12 | final class Http
13 | {
14 | public const POST_METHOD = 'POST';
15 | public const GET_METHOD = 'GET';
16 | public const PUT_METHOD = 'PUT';
17 | public const DELETE_METHOD = 'DELETE';
18 |
19 | public static function doesHttpMethodMatch(string $httpMethod): bool
20 | {
21 | return strtolower($_SERVER['REQUEST_METHOD']) === strtolower($httpMethod);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Route/food-item.routes.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Route;
9 |
10 | use PH7\ApiSimpleMenu\Service\FoodItem;
11 |
12 | enum FoodItemAction: string
13 | {
14 | case RETRIEVE_ALL = 'retrieveall';
15 | case RETRIEVE = 'retrieve';
16 |
17 | public function getResponse(): string
18 | {
19 | $postBody = file_get_contents('php://input');
20 | $postBody = json_decode($postBody); // unused for now
21 |
22 | // Ternary conditional operator operator
23 | $itemId = $_REQUEST['id'] ?? ''; // using the null coalescing operator
24 |
25 | $item = new FoodItem();
26 | $response = match ($this) {
27 | self::RETRIEVE_ALL => $item->retrieveAll(),
28 | self::RETRIEVE => $item->retrieve($itemId),
29 | };
30 |
31 | return json_encode($response);
32 | }
33 | }
34 |
35 | $action = $_REQUEST['action'] ?? null;
36 |
37 | $itemAction = FoodItemAction::tryFrom($action);
38 | if ($itemAction) {
39 | echo $itemAction->getResponse();
40 | } else {
41 | require_once 'not-found.routes.php';
42 | }
43 |
--------------------------------------------------------------------------------
/src/Route/not-found.routes.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Route;
9 |
10 | use PH7\JustHttp\StatusCode;
11 | use PH7\PhpHttpResponseHeader\Http;
12 |
13 | // PHP 7.4 anonymous arrow function
14 | $getResponse = fn(): string => json_encode(['error' => 'Request not found']);
15 |
16 | // Send HTTP 404 Not Found
17 | Http::setHeadersByCode(StatusCode::NOT_FOUND);
18 | echo $getResponse();
19 |
--------------------------------------------------------------------------------
/src/Route/routes.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Route;
9 |
10 | use PH7\ApiSimpleMenu\Route\Exception\NotFoundException;
11 | use PH7\ApiSimpleMenu\Service\Exception\CredentialsInvalidException;
12 | use PH7\ApiSimpleMenu\Validation\Exception\InvalidValidationException;
13 | use PH7\JustHttp\StatusCode;
14 | use PH7\PhpHttpResponseHeader\Http as HttpResponse;
15 |
16 | $resource = $_REQUEST['resource'] ?? null;
17 |
18 | try {
19 | return match ($resource) {
20 | 'user' => require_once 'user.routes.php',
21 | 'item' => require_once 'food-item.routes.php',
22 | default => require_once 'not-found.routes.php',
23 | };
24 | } catch (CredentialsInvalidException $e) {
25 | response([
26 | 'errors' => [
27 | 'message' => $e->getMessage()
28 | ]
29 | ]);
30 | } catch (InvalidValidationException $e) {
31 | // Send 400 http status code
32 | HttpResponse::setHeadersByCode(StatusCode::BAD_REQUEST);
33 |
34 | response([
35 | 'errors' => [
36 | 'message' => $e->getMessage(),
37 | 'code' => $e->getCode()
38 | ]
39 | ]);
40 | } catch (NotFoundException $e) {
41 | // FYI, not-found.Route already sends a 404 Not Found HTTP code
42 | return require_once 'not-found.routes.php';
43 | }
44 |
--------------------------------------------------------------------------------
/src/Route/user.routes.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Route;
9 |
10 | use PH7\ApiSimpleMenu\Route\Exception\NotFoundException;
11 | use PH7\ApiSimpleMenu\Service\Exception\CannotLoginUserException;
12 | use PH7\ApiSimpleMenu\Service\Exception\EmailExistsException;
13 | use PH7\ApiSimpleMenu\Service\SecretKey;
14 | use PH7\ApiSimpleMenu\Service\User;
15 |
16 | use PH7\JustHttp\StatusCode;
17 | use PH7\PhpHttpResponseHeader\Http as HttpResponse;
18 |
19 | enum UserAction: string
20 | {
21 | case LOGIN = 'login';
22 | case CREATE = 'create';
23 | case RETRIEVE_ALL = 'retrieveall';
24 | case RETRIEVE = 'retrieve';
25 | case REMOVE = 'remove';
26 | case UPDATE = 'update';
27 |
28 | /**
29 | * @throws Exception\NotFoundException
30 | */
31 | public function getResponse(): string
32 | {
33 | $postBody = file_get_contents('php://input');
34 | $postBody = json_decode($postBody);
35 |
36 | // Ternary conditional operator operator
37 | $userId = $_REQUEST['id'] ?? ''; // using the null coalescing operator
38 |
39 | // retrieve JWT secret key, and pass it to User Service' constructor
40 | $jwtToken = SecretKey::getJwtSecretKey();
41 | $user = new User($jwtToken);
42 |
43 | try {
44 | // first, let's check if HTTP method for the requested endpoint is valid
45 | $expectHttpMethod = match ($this) {
46 | self::LOGIN => Http::POST_METHOD,
47 | self::CREATE => Http::POST_METHOD,
48 | self::UPDATE => Http::PUT_METHOD,
49 | self::RETRIEVE_ALL => Http::GET_METHOD,
50 | self::RETRIEVE => Http::GET_METHOD,
51 | self::REMOVE => Http::DELETE_METHOD
52 | };
53 |
54 | if (Http::doesHttpMethodMatch($expectHttpMethod) === false) {
55 | throw new NotFoundException('HTTP method is incorrect. Request not found');
56 | }
57 |
58 | $response = match ($this) {
59 | self::LOGIN => $user->login($postBody),
60 | self::CREATE => $user->create($postBody),
61 | self::UPDATE => $user->update($postBody),
62 | self::RETRIEVE_ALL => $user->retrieveAll(),
63 | self::RETRIEVE => $user->retrieve($userId),
64 | self::REMOVE => $user->remove($postBody),
65 | };
66 | } catch (CannotLoginUserException $e) {
67 | // Send 400 http status code
68 | HttpResponse::setHeadersByCode(StatusCode::BAD_REQUEST);
69 |
70 | $response = [
71 | 'errors' => [
72 | 'message' => $e->getMessage(),
73 | ]
74 | ];
75 | } catch (EmailExistsException $e) {
76 | HttpResponse::setHeadersByCode(StatusCode::BAD_REQUEST);
77 |
78 | $response = [
79 | 'errors' => [
80 | 'message' => $e->getMessage()
81 | ]
82 | ];
83 | }
84 |
85 | return json_encode($response);
86 | }
87 | }
88 |
89 | $action = $_REQUEST['action'] ?? null;
90 |
91 | $userAction = UserAction::tryFrom($action);
92 | if ($userAction) {
93 | echo $userAction->getResponse();
94 | } else {
95 | require_once 'not-found.routes.php';
96 | }
97 |
--------------------------------------------------------------------------------
/src/Service/Exception/CannotLoginUserException.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Service\Exception;
9 |
10 | use RuntimeException;
11 |
12 | class CannotLoginUserException extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Service/Exception/CredentialsInvalidException.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Service\Exception;
9 |
10 | use RuntimeException;
11 |
12 | class CredentialsInvalidException extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Service/Exception/EmailExistsException.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu\Service\Exception;
9 |
10 | use RuntimeException;
11 |
12 | class EmailExistsException extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Service/FoodItem.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Service;
11 |
12 | use PH7\ApiSimpleMenu\Dal\FoodItemDal;
13 | use PH7\ApiSimpleMenu\Entity\Item as ItemEntity;
14 | use PH7\ApiSimpleMenu\Validation\Exception\InvalidValidationException;
15 | use Ramsey\Uuid\Uuid;
16 | use Respect\Validation\Validator as v;
17 |
18 | class FoodItem
19 | {
20 | public function retrieve(string $itemUuid): array
21 | {
22 | if (v::uuid()->validate($itemUuid)) {
23 | if ($item = FoodItemDal::get($itemUuid)) {
24 | if ($item->getItemUuid()) {
25 | return [
26 | 'itemUuid' => $item->getItemUuid(),
27 | 'name' => $item->getName(),
28 | 'price' => $item->getPrice(),
29 | 'available' => $item->getAvailable()
30 | ];
31 | }
32 | }
33 |
34 | return [];
35 | }
36 |
37 | throw new InvalidValidationException("Invalid user UUID");
38 | }
39 |
40 | public function retrieveAll(): array
41 | {
42 | $items = FoodItemDal::getAll();
43 |
44 | if (count($items) === 0) {
45 | $this->createDefaultItem();
46 |
47 | // then, get again all items
48 | // to retrieve the new one that just got added
49 | $items = FoodItemDal::getAll();
50 | }
51 |
52 | return $items;
53 | }
54 |
55 | private function createDefaultItem(): void
56 | {
57 | // default item values
58 | $defaultPrice = 19.99;
59 | $isEnabled = true;
60 |
61 |
62 | // if no items have been added yet, create the first one
63 | $itemUuid = Uuid::uuid4()->toString();
64 | $itemEntity = new ItemEntity();
65 |
66 | // chaining each method with the arrow ->
67 | $itemEntity
68 | ->setItemUuid($itemUuid)
69 | ->setName('Burrito Cheese with French Fries')
70 | ->setPrice($defaultPrice)
71 | ->setAvailable($isEnabled);
72 |
73 | FoodItemDal::insertDefaultItem($itemEntity);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Service/SecretKey.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Service;
11 |
12 | use PH7\ApiSimpleMenu\Dal\TokenKeyDal;
13 |
14 | class SecretKey
15 | {
16 | public static function getJwtSecretKey(): string
17 | {
18 | $jwtKey = TokenKeyDal::getSecretKey();
19 |
20 | if (!$jwtKey) {
21 | $uniqueSecretKey = hash('sha512', strval(time()));
22 | TokenKeyDal::saveSecretKey($uniqueSecretKey);
23 |
24 | return $uniqueSecretKey;
25 | }
26 |
27 | return $jwtKey;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Service/User.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | declare(strict_types=1);
9 |
10 | namespace PH7\ApiSimpleMenu\Service;
11 |
12 | use Exception;
13 | use Firebase\JWT\JWT;
14 | use PH7\ApiSimpleMenu\Dal\UserDal;
15 | use PH7\ApiSimpleMenu\Service\Exception\CannotLoginUserException;
16 | use PH7\ApiSimpleMenu\Service\Exception\EmailExistsException;
17 | use PH7\ApiSimpleMenu\Service\Exception\CredentialsInvalidException;
18 | use PH7\ApiSimpleMenu\Validation\Exception\InvalidValidationException;
19 | use PH7\ApiSimpleMenu\Validation\UserValidation;
20 | use PH7\JustHttp\StatusCode;
21 | use PH7\PhpHttpResponseHeader\Http as HttpResponse;
22 | use Ramsey\Uuid\Uuid;
23 | use Respect\Validation\Validator as v;
24 | use PH7\ApiSimpleMenu\Entity\User as UserEntity;
25 |
26 | class User
27 | {
28 | public const DATE_TIME_FORMAT = 'Y-m-d H:i:s';
29 |
30 | public function __construct(protected string $jwtSecretKey)
31 | {
32 | }
33 |
34 | public function login(mixed $data): array
35 | {
36 | $userValidation = new UserValidation($data);
37 | if ($userValidation->isLoginSchemaValid()) {
38 | if (UserDal::doesEmailExist($data->email)) {
39 | $user = UserDal::getByEmail($data->email);
40 |
41 | $areCredentialsValid = $user->getEmail() && password_verify($data->password, $user->getPassword());
42 | if ($areCredentialsValid) {
43 | $userName = "{$user->getFirstName()} {$user->getLastName()}";
44 |
45 | $currentTime = time();
46 | $jwtToken = JWT::encode(
47 | [
48 | 'iss' => $_ENV['APP_URL'],
49 | 'iat' => $currentTime,
50 | 'exp' => $currentTime + $_ENV['JWT_TOKEN_EXPIRATION'],
51 | 'data' => [
52 | 'email' => $data->email,
53 | 'name' => $userName
54 | ]
55 | ],
56 | $this->jwtSecretKey,
57 | $_ENV['JWT_ALGO_ENCRYPTION']
58 | );
59 |
60 | try {
61 | UserDal::setToken($jwtToken, $user->getUserUuid());
62 | } catch (Exception) { // since PHP 8.0, we can omit the caught variable name (e.g. Exception $e)
63 | throw new CannotLoginUserException('Cannot set token to user');
64 | }
65 |
66 | return [
67 | 'message' => sprintf('%s successfully logged in', $userName),
68 | 'token' => $jwtToken
69 | ];
70 | }
71 | }
72 | throw new CredentialsInvalidException('Credentials invalid');
73 | }
74 | throw new InvalidValidationException('Payload invalid');
75 | }
76 |
77 | public function create(mixed $data): array|object
78 | {
79 | $userValidation = new UserValidation($data);
80 | if ($userValidation->isCreationSchemaValid()) {
81 | $userUuid = Uuid::uuid4()->toString(); // assigning a UUID to the user
82 |
83 | $userEntity = new UserEntity();
84 | $userEntity
85 | ->setUserUuid($userUuid)
86 | ->setFirstName($data->first)
87 | ->setLastName($data->last)
88 | ->setEmail($data->email)
89 | ->setPhone($data->phone)
90 | ->setPassword(hashPassword($data->password))
91 | ->setCreationDate(date(self::DATE_TIME_FORMAT));
92 |
93 | $email = $userEntity->getEmail();
94 | if (UserDal::doesEmailExist($email)) {
95 | throw new EmailExistsException(
96 | sprintf('Email address %s already exists', $email)
97 | );
98 | }
99 |
100 | if (!$userUuid = UserDal::create($userEntity)) {
101 | // If we receive an error while creating a user to the database, give a 400 to client
102 | HttpResponse::setHeadersByCode(StatusCode::BAD_REQUEST);
103 |
104 | // Set to empty result, because an issue happened. The client has to handle this properly
105 | $data = [];
106 | }
107 |
108 | // Send a 201 when the user has been successfully added to DB
109 | HttpResponse::setHeadersByCode(StatusCode::CREATED);
110 |
111 | // Add user UUID to the object to give back the user's UUID to the client
112 | $data->userUuid = $userUuid;
113 |
114 | return $data;
115 | }
116 |
117 | throw new InvalidValidationException("Invalid user payload");
118 | }
119 |
120 | public function update(mixed $postBody): array|object
121 | {
122 | $userValidation = new UserValidation($postBody);
123 | if ($userValidation->isUpdateSchemaValid()) {
124 | $userUuid = $postBody->userUuid;
125 |
126 | $userEntity = new UserEntity();
127 | if (!empty($postBody->first)) {
128 | $userEntity->setFirstName($postBody->first);
129 | }
130 |
131 | if (!empty($postBody->last)) {
132 | $userEntity->setLastName($postBody->last);
133 | }
134 |
135 | if (!empty($postBody->phone)) {
136 | $userEntity->setPhone($postBody->phone);
137 | }
138 |
139 | if (UserDal::update($userUuid, $userEntity) === false) {
140 | // Most likely, the user isn't found, set a 404 to the client
141 | HttpResponse::setHeadersByCode(StatusCode::NOT_FOUND);
142 |
143 | // If invalid or got an error, give back an empty response
144 | return [];
145 | }
146 |
147 | return $postBody;
148 | }
149 |
150 | throw new InvalidValidationException("Invalid user payload");
151 | }
152 |
153 | public function retrieveAll(): array
154 | {
155 | return UserDal::getAll();
156 | }
157 |
158 | public function retrieve(string $userUuid): array
159 | {
160 | if (v::uuid()->validate($userUuid)) {
161 | if ($user = UserDal::getById($userUuid)) {
162 | if ($user->getUserUuid()) {
163 | // Retrieve the needed properties we want to expose for the user
164 | return [
165 | 'userUuid' => $user->getUserUuid(),
166 | 'first' => $user->getFirstName(),
167 | 'last' => $user->getLastName(),
168 | 'email' => $user->getEmail(),
169 | 'phone' => $user->getPhone(),
170 | 'creationDate' => $user->getCreationDate()
171 | ];
172 | }
173 | }
174 |
175 | return [];
176 | }
177 |
178 | throw new InvalidValidationException("Invalid user UUID");
179 | }
180 |
181 | /**
182 | * @internal Set `mixed` type, because if we get an incorrect payload with syntax errors, `json_decode` gives NULL,
183 | * and `object` wouldn't be a valid datatype here.
184 | */
185 | public function remove(mixed $data): bool
186 | {
187 | $userValidation = new UserValidation($data);
188 | if ($userValidation->isRemoveSchemaValid()) {
189 | // Send a 204 if the user got removed
190 | //HttpResponse::setHeadersByCode(StatusCode::NO_CONTENT);
191 | return UserDal::remove($data->userUuid);
192 | }
193 |
194 | throw new InvalidValidationException("Invalid user UUID");
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/Validation/Exception/InvalidValidationException.php:
--------------------------------------------------------------------------------
1 | length(self::MINIMUM_NAME_LENGTH, self::MAXIMUM_NAME_LENGTH))
34 | ->attribute('last', v::stringType()->length(self::MINIMUM_NAME_LENGTH, self::MAXIMUM_NAME_LENGTH))
35 | ->attribute('password', v::stringType()->length(self::MINIMUM_PASSWORD_LENGTH))
36 | ->attribute('email', v::email())
37 | ->attribute('phone', v::phone());
38 |
39 | return $schemaValidation->validate($this->data);
40 | }
41 |
42 | public function isRemoveSchemaValid(): bool
43 | {
44 | return v::attribute('userUuid', v::uuid())->validate($this->data);
45 | }
46 |
47 | public function isUpdateSchemaValid(): bool
48 | {
49 | $schemaValidation =
50 | v::attribute('userUuid', v::uuid())
51 | ->attribute('first', v::stringType()->length(self::MINIMUM_NAME_LENGTH, self::MAXIMUM_NAME_LENGTH), mandatory: false)
52 | ->attribute('last', v::stringType()->length(self::MINIMUM_NAME_LENGTH, self::MAXIMUM_NAME_LENGTH), mandatory: false)
53 | ->attribute('phone', v::phone(), mandatory: false);
54 |
55 | return $schemaValidation->validate($this->data);
56 | }
57 |
58 | public function isLoginSchemaValid(): bool
59 | {
60 | $schemaValidation =
61 | v::attribute('email', v::stringType())
62 | ->attribute('password', v::stringType());
63 |
64 | return $schemaValidation->validate($this->data);
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/config/config.inc.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu;
9 |
10 | use Dotenv\Dotenv;
11 |
12 | enum Environment : string
13 | {
14 | case DEVELOPMENT = 'development';
15 | case PRODUCTION = 'production';
16 |
17 | public function environmentName(): string
18 | {
19 | return match($this) {
20 | self::DEVELOPMENT => 'development',
21 | self::PRODUCTION => 'production'
22 | };
23 | }
24 | }
25 |
26 | $path = dirname(__DIR__, 2);
27 | $dotenv = Dotenv::createImmutable($path);
28 | $dotenv->load();
29 |
30 | // optional: check if the necessary values are in the .env file
31 | $dotenv->required(['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS']);
32 |
--------------------------------------------------------------------------------
/src/config/database.inc.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu;
9 |
10 | use RedBeanPHP\R;
11 |
12 | // setup RedBean
13 | $dsn = sprintf('mysql:host=%s;dbname=%s', $_ENV['DB_HOST'], $_ENV['DB_NAME']);
14 | R::setup($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS']);
15 |
16 | // Freeze RedBean on production
17 | $currentEnvironment = Environment::tryFrom($_ENV['ENVIRONMENT']);
18 | if ($currentEnvironment?->environmentName() !== Environment::DEVELOPMENT->value) {
19 | echo 'RedBean Frozen';
20 | R::freeze();
21 | }
22 |
--------------------------------------------------------------------------------
/src/helpers/headers.inc.php:
--------------------------------------------------------------------------------
1 |
4 | * @website https://ph7.me
5 | * @license MIT License
6 | */
7 |
8 | namespace PH7\ApiSimpleMenu;
9 |
10 | use PH7\PhpHttpResponseHeader\Http;
11 |
12 | (new AllowCors)->init();
13 |
14 | Http::setContentType('application/json');
15 |
--------------------------------------------------------------------------------
/src/helpers/misc.inc.php:
--------------------------------------------------------------------------------
1 |
6 | * @license MIT License
7 | */
8 |
9 | declare(strict_types=1);
10 |
11 | const PASSWORD_COST_FACTOR = 12;
12 |
13 | function response(mixed $data): void {
14 | echo json_encode($data);
15 | }
16 |
17 | function hashPassword(string $password): false|null|string {
18 | return password_hash($password, PASSWORD_ARGON2I, ['cost' => PASSWORD_COST_FACTOR]);
19 | }
20 |
--------------------------------------------------------------------------------