You don't have to go fully Apie

 

So you have an existing Laravel or Symfony application and you think: "I wish I could use the Apie library, but I'm afraid I have to rewrite my entire application to use it." Well, that is a wrong thought! Apie can easily be integrated in an existing Laravel or Symfony application. You can even still use your own Doctrine entities or Eloquent models. So how to do this?

Add Rest API endpoints to OpenAPI

Apie generates an OpenAPI spec and anything written by Apie is added to it as long you include the composer package apie/rest-api. If you look at the Apie configuration in your application (apie.yaml in Symfony and apie.php in Laravel) you will see this part:

apie:
  cms:
    dashboard_template: 'apie/dashboard.html.twig'
  doctrine:
    connection_params:
      driver: pdo_sqlite
      path: "%kernel.project_dir%/db.sqlite"
  datalayers:
    default_datalayer: 'Apie\DoctrineEntityDatalayer\DoctrineEntityDatalayer'
  bounded_contexts:
    example:
      entities_folder: "%kernel.project_dir%/src/Apie/Example/Resources/"
      entities_namespace: 'App\Apie\Example\Resources'
      actions_folder: "%kernel.project_dir%/src/Apie/Example/Actions/"
      actions_namespace: 'App\Apie\Example\Actions'
For example you can configure the error template or dashboard template of the apie/cms package, but the thing I'm interested in is the bounded_contexts. A bounded context contains a list of Resources, but also Actions. Resources are for CRUD and is fully Apie, but Actions can call anything from your application and we can mimic resources with our own Doctrine Entities or Eloquent models.

Example action

Basically an action is mapping all public methods to a Rest API as POST /{classname}/{methodname} and all function arguments are sent in the body of the request. The return value of the method is what you get back.

namespace App\Apie\Example\Actions;

class Calculator
{
    public function plus(float $numberOne, float $numberTwo): float
    {
        return $numberOne + $numberTwo;
    }
}
Apie will make a POST /calculator/plus API call and expects a POST body like
{"numberOne": 12,"numberTwo": 13}
It will return 25 in the response body.


We can also modify the routing and provide the input in the input. The cool thing is that this action is framework agnostic, so I can just move this class from a Symfony application and put it in a Laravel application application.

namespace App\Apie\Example\Actions;

use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\Route;
use Apie\Core\Enums\RequestMethod;

class Calculator
{
    #[Route('calc/{numberOne}/plus/{numberTwo}', requestMethod: RequestMethod::GET)]
    public function plus(#[Context] float $numberOne, #[Context] float $numberTwo): float
    {
        return $numberOne + $numberTwo;
    }
}
Now we have a GET api call for example /calc/12/plus/13. It's very important to have the #[Context] attribute on the method arguments or it will still try to get these from the request body. Any change will also be reflected in the OpenAPI specification.




Our action can use anything, so for example if we have a Laravel application with a Country eloquent model we can use it an Apie action very easily, It's also possible to use regular dependency injection or use the Laravel facades (my tip: don't use them).

namespace App\Apie\Example\Actions;

use App\Models\Country;
use App\Resources\CountryResource;
use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\Route;
use Apie\Core\Enums\RequestMethod;

class CountryActions
{
    #[Route('countries/{countryId}', requestMethod: RequestMethod::GET)]
    public function get(#[Context] int $countryId): CountryResource
    {
        return new CountryResource(Country::findOrFail($countryId));
    }

    #[Route('countries', requestMethod: RequestMethod::GET)]
    public function all(): Collection
    {
        return CountryResource::collection(Country::all());
    }
}
Apie will generate the routes for you and because Laravel resources implement JsonSerializable it will output the resource just as you expected. So in a nutshell you can keep using everything a framework provides.

OpenAPI Schema

Laravel form requests and resources have one serious drawback: it's impossible to make a good JSON schema with reflection from those objects. We could however provide a JSON schema for an object with the ProvideSchema attribute.

use Apie\Core\Attributes\SchemaMethod;
use cebe\openapi\spec\Schema;
use Illuminate\Http\Resources\Json\JsonResource;

#[SchemaMethod('provideJsonSchema')]
class CountryResource extends JsonResource
{
    public function toArray()
    {
       return [
           'id' => $this->id,
           'countryCode' => $this->countryCode,
       ];
    }
    
    public static function provideJsonSchema(): Schema
    {
        return new Schema([
            'type' => 'object',
            'properties' => [
                'id' => new Schema(['type' => 'number']),
                'countryCode' => new Schema(['type' => 'string', 'example' => 'NL']),
            ],
        ]);
    }
}
Of course unlike the calculator example above, this example only works in an other Laravel application, but replacing the CountryResource with a CountryDto should not be hard. Remember these actions are not controllers and Symfony param converters or Laravel SubstituteBinding middleware will not work!

Adding old controller actions to OpenAPI spec

If you are dealing with lots of controller actions or you want to use the default application authentication, then it's possible to provide a default OpenAPI.yaml that Apie will extend.

In a Laravel application you need to register a service with the name cebe\openapi\spec\OpenApi using the OpenApi reader:

use cebe\openapi\Reader;
use cebe\openapi\ReferenceContext;
use cebe\openapi\spec\OpenApi;

class OpenApiLoader {
	public static function load(): OpenApi
    {
        return Reader::::readFromYamlFile(
            __DIR__ . '/../../openapi.yaml',
            OpenApi::class,
            ReferenceContext::RESOLVE_MODE_INLINE
        );
    }
}

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OpenApi::class, function () {
            return OpenApiLoader::load();
        });
    }
}
In Symfony we can register a service in services.yaml:

services:
    cebe\openapi\spec\OpenApi:
        factory: [OpenApiLoader, 'load']
You need to do this manually, but you can add everything. Using an identity provider with Oauth? just define it in the OpenAPI spec to add it. All it requires is good knowledge how to build en OpenAPI spec. But of course you can also document any non-Apie controller action just fine!

Sharing the user object

You can make Apie login with a user. In Laravel this works out of the box, but in Symfony you require to setup a User authenticator class in security.yaml manually. In the background Apie decorates the User entity with Decorator class. A simple current user action can be made like this:

use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\Route;
use Apie\Core\Entities\EntityInterface;
use Apie\Core\Enums\RequestMethod;

class Authentication
{
    #[Route('/me', RequestMethod::GET)]
    #[Route('/profile', target: Route::CMS)]
    public function currentUser(
        #[Context('authenticated')] ?EntityInterface $currentUser = null
    ): EntityInterface {
        return $currentUser;
    }
}
The other way around it's also possible to get the currently logged in user. Since Laravel and Symfony use different user objects, this solution is not framework agnostic, but are very similar:


use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\Route;
use Apie\Core\Enums\RequestMethod;
use Illuminate\Contracts\Auth\Authenticatable;
use Symfony\Component\Security\Core\User\UserInterface;

class LaravelAuthentication
{
    #[Route('/me', RequestMethod::GET)]
    #[Route('/profile', target: Route::CMS)]
    public function currentUser(
        #[Context()] ?Authenticatable $currentUser = null
    ): ?object {
        return $currentUser;
    }
}

class SymfonyAuthentication
{
    #[Route('/me', RequestMethod::GET)]
    #[Route('/profile', target: Route::CMS)]
    public function currentUser(
        #[Context()] ?UserInterface $currentUser = null
    ): ?object {
        return $currentUser;
    }
}

One small detail: the default value is null of this argument, so that if you are not logged in you will get null as response and not a 403 access denied response. Also if you provide a different return value, the OpenAPI spec will also be changed accordingly.

This part requires no configuration if you are in Symfony and you can get the Symfony user in Apie very easily. For Laravel you might need to configure Apie using the correct authentication middleware to make everything work. That's all for now!

Comments