slimphp / Slim-Skeleton

Slim Framework 4 Skeleton Application
http://www.slimframework.com
MIT License
1.59k stars 479 forks source link

Request an example of a POST inside the skeleton #222

Closed Guyverix closed 3 years ago

Guyverix commented 3 years ago

I have been learning a lot by using your skeleton, however I am having some trouble with POST.

The GET makes sense, but since I am not a real developer, I am having a tough time making a POST without the application barfing on my ignorance. Any chance that a POST in the routes can be added with a simple/complete example?

l0gicgate commented 3 years ago

Sure let's do a simple signup endpoint for example:

Let's add a signup route

routes.php

$app->post('/sign-up', SignUpUserAction::class);

Create src/Application/Validation folder

Let's make an abstract validator so we can extend it for specific use cases like below. You'll need to install webmozart/assert via composer for this code to work: src/Application/Validation/Validator.php

<?php

declare(strict_types=1);

namespace App\Application\Validation;

use InvalidArgumentException;

abstract class Validator
{
    /**
     * @throws ValidationException
     */
    public function validate(array $data): void
    {
        try {
            $this->__validate($data);
        } catch (InvalidArgumentException $exception) {
            throw new ValidationException($exception->getMessage());
        }
    }

    /**
     * @throws InvalidArgumentException
     */
    abstract public function __validate(array $data): void;
}

Named Validator Exception src/Application/Validation/ValidationException.php

<?php

declare(strict_types=1);

namespace App\Application\Validation;

use App\Application\Exception\ApplicationException;

class ValidationException extends ApplicationException
{
}

SignUp Validator src/Application/Validation/User/SignUpValidator.php

<?php

declare(strict_types=1);

namespace App\Application\Validation\User;

use App\Application\Validation\Validator;
use Webmozart\Assert\Assert;

class SignUpValidator extends Validator
{
    public function __validate(array $data): void
    {
        Assert::stringNotEmpty($data['firstName'], 'Field `firstName` cannot be empty.');
        Assert::maxLength($data['firstName'], 255, 'Field `firstName` be longer than 255 characters.');
        Assert::stringNotEmpty($data['lastName'], 'Field `lastName` cannot be empty.');
        Assert::maxLength($data['lastName'], 255, 'Field `lastName` cannot be longer than 255 characters.');
        Assert::stringNotEmpty($data['username'], 'Field `username` cannot be empty.');
        Assert::maxLength($data['username'], 255, 'Field `username` cannot be longer than 255 characters.');
    }
}

Now let's modify the base action class to capture ValidationException so it can rethrow it as an HttpBadRequestException: Action.php src/Application/Actions/Action.php

<?php
declare(strict_types=1);

namespace App\Application\Actions;

use App\Domain\DomainException\DomainRecordNotFoundException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpNotFoundException;

abstract class Action
{
    /**
     * @var LoggerInterface
     */
    protected $logger;

    /**
     * @var Request
     */
    protected $request;

    /**
     * @var Response
     */
    protected $response;

    /**
     * @var array
     */
    protected $args;

    /**
     * @param LoggerInterface $logger
     */
    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    /**
     * @param Request $request
     * @param Response $response
     * @param array $args
     * @return Response
     * @throws HttpNotFoundException
     * @throws HttpBadRequestException
     */
    public function __invoke(Request $request, Response $response, array $args): Response
    {
        $this->request = $request;
        $this->response = $response;
        $this->args = $args;

        try {
            return $this->action();
        } catch (DomainRecordNotFoundException $e) {
            throw new HttpNotFoundException($this->request, $e->getMessage());
        } catch (ValidationException $e) {
            throw new HttpBadRequestException($this->request, $e->getMessage());
        }
    }

    // Replace the original getFormData() method with this one
    protected function getFormData(): array
    {
        return $this->request->getParsedBody() ?? [];
    }
}

SignUpAction: src/Application/Actions/User/SignUpAction.php

<?php

declare(strict_types=1);

namespace App\Application\Actions\User;

use App\Application\Validation\User\SignUpValidator;
use App\Domain\User\User;
use App\Domain\User\UserRepository;
use Psr\Http\Message\ResponseInterface as Response;

class SignUpAction extends UserAction
{
    protected function action(): Response
    {
        $data = $this->getFormData();
        $validator = new SignUpValidator();
        $validator->validate($data);

        // I'm not going to go into the persistence of the user here because
        // I don't know what you're using to store stuff in your database
        // If you're using an ORM this should be fairly easy though.
        $user = new User(1, $data['firstName'], $data['lastName'], $data['username']);
        $this->userRepository->store($user);    

        return $this->respondWithData($user);
    }
}

A sample request would be:

POST /sign-up
Content-Type: application/json
{"firstName": "John", "lastName": "Doe", "username": "johndoe"}
Guyverix commented 3 years ago

ROFL! Thats amazing how you were able to knock that out so fast!

Much of what I have been doing is monkey-see, monkey-do. This will help me out a lot with the second part of my project. Using google I have been able to find help with the framework and GET but POSTs did not have as much documentation, and what I could find was not clear enough for a beginner.

I have about 10 API routes written with GETs that I have working the way I like, but need to get more complex with POSTs and just was not able to wrap my head around how the framework expected it to be done.

Thank you very much!

l0gicgate commented 3 years ago

ROFL! Thats amazing how you were able to knock that out so fast!

It was easy to pull from some of my existing projects. Also, there's bits around storing entities not included for the sake of this example as it would get way too complicated but hopefully this is a good platform to start from.

Perhaps we should include this in the skeleton.

Thank you very much!

You're welcome 😄

Guyverix commented 3 years ago

I have added your examples, but running into errs about the class not existing. Assumed that SignUpUserAction is supposed to call SignUpAction class that was declared, but still not working. Any suggestions on how I can further debug this?

This is what I get no matter which class name I define in route.php: curl -X POST -i http://larvel01:8002/sign-up --data '{"firstName": "John", "lastName": "Doe", "username": "johndoe"}' HTTP/1.1 500 Internal Server Error Host: larvel01:8002 Date: Thu, 22 Apr 2021 21:11:41 GMT Connection: close X-Powered-By: PHP/7.4.16 Content-Type: application/json Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: * Access-Control-Allow-Headers: X-Requested-With, Content-Type, Accept, Origin, Authorization Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS Cache-Control: no-store, no-cache, must-revalidate, max-age=0 Cache-Control: post-check=0, pre-check=0 Pragma: no-cache

{ "statusCode": 500, "error": { "type": "SERVER_ERROR", "description": "Callable SignUpAction does not exist" }

Guyverix commented 3 years ago

Found the start of my problem with the example. Needs a use at the top of routes.php for SignUpAction.. If I figure out how I messed this up on the next part after this, I will update. route.php: (added) use App\Application\Actions\User\SignUpAction;

Guyverix commented 3 years ago

Guessing a collision between the $user defined in SignUpAction and the in memory users defined in the skeleton for the InMemoryUserRepository.php..

{ "statusCode": 500, "error": { "type": "SERVER_ERROR", "description": "Call to undefined method App\Infrastructure\Persistence\User\InMemoryUserRepository::store()" } }

Guyverix commented 3 years ago

Final update (promise), since I got it mostly working. At least enough to find out how to POST. But wanted to make sure you knew one other area that would need an update in the skeleton itself which is that InMemoryUserRepository.php gets called from the SignUpAction.php, and freaks out since there is no store() defined.

I defined it and just return what was passed ($user), however it messes up the data returned, but at least does a JSON return with data. It is just messed up on the order of data. the firstName defined in the array is returning the last name, etc. Guessing it is mixing with the existing in-memory store that was already part of the users example when defined like this: public function store(): array { return array_values($this->users); }

But at least it is returning data for me :) I figured I should let you know about this behavior before you add POST examples into the main framework in the future and have to dig to find what is goofy.

Thank you again for giving me such a good example to use and learn from, I really appreciate your time on this.

l0gicgate commented 3 years ago

InMemoryUserRepository.php gets called from the SignUpAction.php, and freaks out since there is no store() defined.

As I commented in the code, you would need to implement your storage solution. The InMemoryUserRepository is just a placeholder for an ORM like Doctrine or whatever you want to use.

public function store(): array { return array_values($this->users); }

That function should store the new user entity in the storage. It'd be something like:

public function store(User $user): User
{
    $this->users[] = $user;
    return $user;
}

Thank you again for giving me such a good example to use and learn from, I really appreciate your time on this.

No problem. I am closing this issue as resolved.