Skip to content

Commit 2630797

Browse files
author
bta2bj
committed
On branch main
Initial commit
0 parents  commit 2630797

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+18553
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
log/cache
2+
vendor
3+
node_modules
4+
.idea
5+
temp/
6+
www/dist

.htaccess

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Order Allow,Deny
2+
Deny from all

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Test project REST API
2+
## APITTE Project with Swagger, Docker, PHP 8.1 and Nette 3.1
3+
4+
This project is a REST API built with Apitte, Docker, PHP 8.1, and Nette 3.1.
5+
## Endpoints:
6+
- `/api/v1/product/all`: Get all products with filter (query string)
7+
- `/api/v1/product/one`: Get a single product by ID (query string)
8+
- `/api/v1/product/delete`: Delete a product by ID (query string)
9+
- `/api/v1/product/new`: Create a new product (json body)
10+
- `/api/v1/product/update`: Update a product by ID (json body)
11+
12+
## Setup Instructions
13+
14+
*If folder name log does not exist, please create it to make project work properly.*
15+
16+
1. Clone the repository
17+
18+
2. Navigate into the cloned directory and start the project with Docker:
19+
20+
`docker compose up -d`
21+
22+
3. Install the NPM dependencies:
23+
24+
`npm install`
25+
26+
4. Build the composer dependencies by running:
27+
28+
`docker compose exec php composer install`
29+
30+
5. Build the database by running the migrations with this command:
31+
32+
`docker compose exec php composer run run-migration`
33+
34+
## Swagger, Adminer, and API Endpoints
35+
After setting up, the following services can be accessed as shown:
36+
37+
- Swagger UI: `http://localhost:9001`
38+
- Adminer (for database management): `http://localhost:8181`
39+
- API endpoints (for testing): `http://localhost:8080/api/v1/`
40+
41+
To handle CORS, the `CorsMiddleware` middleware will be used to add appropriate CORS headers to the response.
42+
43+
## Planned
44+
45+
### Authentication
46+
Although authentication is not yet implemented, the Plan is to use JWT Bearer tokens for authentication. With this method, after a user logs in, a token would be generated for the user which will be used to make authenticated requests to the server.
47+
48+
The `AuthenticationDecorator` middleware will verify the token present in the Authorization header of the request.
49+
50+
### Caching
51+
Caching is another planned thing that would serve to ease queries to the database and speed up api response.
52+
53+
### Filter
54+
Adding more complex filter to search for example "from" "to" range.
55+
56+
### Tests
57+
For testing purpose I plan to use PHP Unit tester.
58+
59+
### Versions
60+
Versioning strategy is crucial for maintaining compatibility and providing a grace period for clients to adjust to new versions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace App\Api\Decorators;
4+
5+
use Apitte\Core\Decorator\IRequestDecorator;
6+
use Apitte\Core\Exception\Api\ClientErrorException;
7+
use Apitte\Core\Http\ApiRequest;
8+
use Apitte\Core\Http\ApiResponse;
9+
use Apitte\Core\Http\RequestAttributes;
10+
11+
class AuthenticationDecorator implements IRequestDecorator
12+
{
13+
public function decorateRequest(ApiRequest $request, ApiResponse $response): ApiRequest
14+
{
15+
$endpoint = $request->getAttribute(RequestAttributes::ATTR_ENDPOINT);
16+
17+
if ($endpoint->hasTag('OpenApi')) {
18+
return $request;
19+
}
20+
21+
$authHeader = $request->getHeader('Authorization');
22+
if (count($authHeader) === 0) {
23+
throw ClientErrorException::create()
24+
->withMessage('Missing Authorization')
25+
->withCode(401);
26+
}
27+
28+
$jwt = str_replace('Bearer ', '', $authHeader);
29+
if (empty($jwt)) {
30+
throw ClientErrorException::create()
31+
->withMessage('Invalid Bearer token')
32+
->withCode(401);
33+
}
34+
35+
return $request->withAttribute('jwt', 'test');
36+
}
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace App\Api\Middlewares;
4+
5+
use Contributte\Middlewares\IMiddleware;
6+
use Psr\Http\Message\ResponseInterface;
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
class CORSMiddleware implements IMiddleware
10+
{
11+
private function decorate(ResponseInterface $response): ResponseInterface
12+
{
13+
return $response
14+
->withHeader('Access-Control-Allow-Origin', '*')
15+
->withHeader('Access-Control-Allow-Credentials', 'true')
16+
->withHeader('Access-Control-Allow-Methods', '*')
17+
->withHeader('Access-Control-Allow-Headers', '*');
18+
}
19+
20+
public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next): ResponseInterface
21+
{
22+
if ($request->getMethod() === 'OPTIONS') {
23+
return $this->decorate($response);
24+
}
25+
26+
/** @var ResponseInterface $response */
27+
$response = $next($request, $response);
28+
29+
return $this->decorate($response);
30+
}
31+
32+
}

app/Api/V1/BaseV1Controller.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace App\Api\V1;
4+
5+
use Apitte\Core\Annotation\Controller\Path;
6+
use Apitte\Core\UI\Controller\IController;
7+
use Apitte\OpenApi\Schema\SecurityScheme;
8+
9+
#[Path("/api/v1")]
10+
abstract class BaseV1Controller implements IController
11+
{
12+
}

app/Api/V1/OpenApiController.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Api\V1;
4+
5+
use Apitte\Core\Annotation\Controller\Method;
6+
use Apitte\Core\Annotation\Controller\Path;
7+
use Apitte\Core\Annotation\Controller\Tag;
8+
use Apitte\Core\Http\ApiRequest;
9+
use Apitte\Core\Http\ApiResponse;
10+
use Apitte\Core\UI\Controller\IController;
11+
use Apitte\OpenApi\ISchemaBuilder;
12+
13+
#[Path("/api/v1/openapi")]
14+
#[Tag('OpenApi')]
15+
final class OpenApiController implements IController
16+
{
17+
public function __construct(protected ISchemaBuilder $builder)
18+
{
19+
}
20+
21+
#[Path("/")]
22+
#[Method('GET')]
23+
public function index(ApiRequest $request, ApiResponse $response): ApiResponse
24+
{
25+
$openApi = $this->builder->build();
26+
27+
return $response
28+
->withAddedHeader('Access-Control-Allow-Origin', '*')
29+
->withAddedHeader('Access-Control-Allow-Methods', 'GET, POST')
30+
->withAddedHeader('Access-Control-Allow-Headers', 'Content-Type')
31+
->writeJsonBody($openApi->toArray());
32+
}
33+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Api\V1\Products\Models\Request;
4+
5+
use Apitte\Core\Mapping\Request\BasicEntity;
6+
use Symfony\Component\Validator\Constraints\NotBlank;
7+
8+
final class ProductNewRequest extends BasicEntity
9+
{
10+
#[NotBlank]
11+
public string $name;
12+
#[NotBlank]
13+
public float $price;
14+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Api\V1\Products\Models\Request;
4+
5+
use Apitte\Core\Mapping\Request\BasicEntity;
6+
use Symfony\Component\Validator\Constraints\NotBlank;
7+
8+
final class ProductUpdateRequest extends BasicEntity
9+
{
10+
#[NotBlank]
11+
public int $id;
12+
#[NotBlank]
13+
public string $name;
14+
#[NotBlank]
15+
public float $price;
16+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace App\Api\V1\Products\Models\Response;
4+
5+
use Apitte\Core\Mapping\Response\BasicEntity;
6+
use App\Models\Product;
7+
8+
final class ProductResponse extends BasicEntity
9+
{
10+
public int $id;
11+
public string $name;
12+
public float $price;
13+
public \DateTime $createdAt;
14+
public ?\DateTime $updatedAt = null;
15+
public ?\DateTime $deletedAt = null;
16+
17+
public static function from(Product $product): self
18+
{
19+
$self = new self();
20+
$self->id = $product->getId();
21+
$self->name = $product->getName();
22+
$self->price = $product->getPrice();
23+
$self->createdAt = $product->getCreatedAt();
24+
$self->updatedAt = $product->getUpdatedAt();
25+
$self->deletedAt = $product->getDeletedAt();
26+
27+
return $self;
28+
}
29+
30+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
namespace App\Api\V1\Products;
4+
5+
use Apitte\Core\Annotation\Controller\Method;
6+
use Apitte\Core\Annotation\Controller\Path;
7+
use Apitte\Core\Annotation\Controller\RequestBody;
8+
use Apitte\Core\Annotation\Controller\RequestParameter;
9+
use Apitte\Core\Annotation\Controller\Response;
10+
use Apitte\Core\Annotation\Controller\Tag;
11+
use Apitte\Core\Exception\Api\ClientErrorException;
12+
use Apitte\Core\Exception\Api\ServerErrorException;
13+
use Apitte\Core\Http\ApiRequest;
14+
use Apitte\Core\Http\ApiResponse;
15+
use Apitte\Negotiation\Http\ArrayEntity;
16+
use App\Api\V1\BaseV1Controller;
17+
use App\Api\V1\Products\Models\Request\ProductNewRequest;
18+
use App\Api\V1\Products\Models\Request\ProductUpdateRequest;
19+
use App\Api\V1\Products\Models\Response\ProductResponse;
20+
use App\Facades\ProductFacade;
21+
use Nette\Http\IResponse;
22+
use Nette\InvalidStateException;
23+
24+
#[Path('/product')]
25+
#[Tag('Product')]
26+
class ProductController extends BaseV1Controller
27+
{
28+
public function __construct(protected ProductFacade $productFacade)
29+
{
30+
}
31+
32+
#[Path('/all')]
33+
#[Method('GET')]
34+
#[RequestParameter(name:"limit", type:"int", in:"query", required:false, description:"Data limit")]
35+
#[RequestParameter(name:"offset", type:"int", in:"query", required:false, description:"Data offset")]
36+
#[Response(description: "Success", code: "200", entity: ProductResponse::class)]
37+
#[Response(description: "Not found", code: "404")]
38+
public function getAllProducts(ApiRequest $request, ApiResponse $response): ApiResponse
39+
{
40+
$limit = $request->getParameter('limit', 10);
41+
$offset = $request->getParameter('offset', 0);
42+
return $response->withStatus(ApiResponse::S200_OK)
43+
->withEntity(ArrayEntity::from($this->productFacade->getAllProducts(limit: $limit, offset: $offset)));
44+
}
45+
46+
#[Path('/one')]
47+
#[Method('GET')]
48+
#[RequestParameter(name: "id", type: "int", in: "query", description: "Product ID")]
49+
#[Response(description: "Success", code: "200", entity: ProductResponse::class)]
50+
#[Response(description: "Not found", code: "404")]
51+
public function getOneProduct(ApiRequest $request, ApiResponse $response): ApiResponse
52+
{
53+
try {
54+
$id = self::toInt($request->getParameter('id'));
55+
return $response->withStatus(ApiResponse::S200_OK)
56+
->withEntity(ArrayEntity::from([$this->productFacade->getProductById($id)]));
57+
} catch (\Throwable $ex) {
58+
throw ClientErrorException::create()
59+
->withMessage('Product not found')
60+
->withCode(IResponse::S404_NotFound);
61+
}
62+
}
63+
64+
#[Path('/new')]
65+
#[Method('POST')]
66+
#[Response(description: "Successfully created", code: "201")]
67+
#[Response(description: "Failed to create", code: "404")]
68+
#[RequestBody('Create new product by structure', ProductNewRequest::class)]
69+
public function createProduct(ApiRequest $request, ApiResponse $response): ApiResponse
70+
{
71+
/** @var ProductNewRequest $entity */
72+
$entity = $request->getEntity();
73+
try {
74+
return $response->withStatus(IResponse::S201_Created)
75+
->withEntity(ArrayEntity::from([$this->productFacade->createNewProductFromRequest($entity)]))
76+
->withHeader('Content-Type', 'application/json');
77+
} catch (\Throwable $e) {
78+
throw ServerErrorException::create()
79+
->withMessage('Cannot create the product')
80+
->withPrevious($e);
81+
}
82+
}
83+
84+
#[Path('/update')]
85+
#[Method('PUT')]
86+
#[Response(description: "Successfully updated", code: "201")]
87+
#[Response(description: "Failed to update", code: "404")]
88+
#[RequestBody('Update product by structure', ProductUpdateRequest::class)]
89+
public function updateProductById(ApiRequest $request, ApiResponse $response): ApiResponse
90+
{
91+
/** @var ProductUpdateRequest $entity */
92+
$entity = $request->getEntity();
93+
try {
94+
return $response->withStatus(IResponse::S201_Created)
95+
->withEntity(ArrayEntity::from([$this->productFacade->updateProductByEntity($entity)]))
96+
->withHeader('Content-Type', 'application/json');
97+
} catch (\Throwable $e) {
98+
throw ServerErrorException::create()
99+
->withMessage('Cannot create the product')
100+
->withPrevious($e);
101+
}
102+
}
103+
104+
#[Path('/delete')]
105+
#[Method('DELETE')]
106+
#[RequestParameter(name: "id", type: "int", in: "query", description: "Product ID")]
107+
#[Response(description: "Success", code: "200", entity: ProductResponse::class)]
108+
#[Response(description: "Not found", code: "404")]
109+
public function deleteProductById(ApiRequest $request, ApiResponse $response): ApiResponse
110+
{
111+
$id = self::toInt($request->getParameter('id'));
112+
try {
113+
return $response->withStatus(ApiResponse::S200_OK)
114+
->withEntity(ArrayEntity::from([$this->productFacade->deleteProductById($id)]));
115+
} catch (\Throwable $ex) {
116+
throw ClientErrorException::create()
117+
->withMessage('Product not found')
118+
->withCode(IResponse::S404_NotFound);
119+
}
120+
}
121+
122+
private static function toInt(mixed $value): int
123+
{
124+
if (is_string($value) || is_int($value) || is_float($value)) {
125+
return (int)$value;
126+
}
127+
128+
throw new InvalidStateException('Cannot cast to integer');
129+
}
130+
}

0 commit comments

Comments
 (0)