Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom processor for creating annotations programatically #1592

Open
tobimori opened this issue May 6, 2024 · 4 comments
Open

Custom processor for creating annotations programatically #1592

tobimori opened this issue May 6, 2024 · 4 comments
Labels

Comments

@tobimori
Copy link

tobimori commented May 6, 2024

I have a route that looks like this

class IndexRoute extends Route
{
  public function get(): Response
  {
    return $this->createResponse(200, ['foo' => 'bar']);
  }
  
  public function post(): Response
  {
    return $this->createResponse(200, ['foo' => 'bar']);
  }
  
  public function patch(): Response
  {
    return $this->createResponse(200, ['foo' => 'bar']);
  }
  
  // [...]
}

Now, I want to write a custom processor that automatically adds the corresponding OA\Operation annotation. For a later step, it'd be my goal to add OA\Response annotations based on the $this->createResponse() calls.

I tried the following:

class OpenApiProcessor implements ProcessorInterface
{
  public function __invoke(Analysis $analysis)
  {
    foreach ($analysis->classes as $className => $class) {
      if (is_subclass_of($className, Route::class)) {
        $path = Router::instance(['dir' => dirname(__DIR__, 2) . '/routes'])->fileToPattern($class['context']->filename);
        foreach ($class['methods'] as $name => $method) {
          // Skip methods that are not HTTP methods
          if (!A::has($className::methods(), Str::upper($name))) {
            continue;
          }

          $operation = new ("OpenApi\\Annotations\\" . Str::ucfirst($name))(
            [
              'path' => $path,
              '_context' => new Context(['generated' => true], $method)
            ]
          );

          $analysis->addAnnotation($operation, $analysis->context);
        }
      }
    }
  }
}

However, it seems like the addAnnotation is not the correct method to use here. What would be the correct approach to this?

@uuf6429
Copy link

uuf6429 commented May 11, 2024

I have a similar situation. In particular, I want to avoid polluting my source code with information that already exists. E.g. endpoint paths are already defined in the router (configuration), so it doesn't make sense to add annotations to controllers/endpoints.
On a similar level, my endpoints have fixed return types that also denote status codes etc, so again I would want to avoid duplicating that info as attributes/annotations:

// routes.php
$router->get('x/y', Controllers\X\Y::class);

// Controllers/X/Y.php
/**
 * Returns data for Y.         <-- Simple PHPDoc that should show up as OA endpoint description.
 *                                 To add more OA-specific details, I would just need to add OA-specific attributes/annotations
 */
class Y {
    public function __invoke(): Responses\Y|Responses\NotFound
    // ^ if we follow the thought that this method will never cause an exit/die and unhandled exceptions trigger a 500
    // server error, then we can reasonably assuming that there will only be 3 distinct types of responses.
    {
        $model = $this->repo->findModel();
        return $model
            ? new Responses\Y($model)
            : new Responses\NotFound()
    }
}

// Responses/NotFound.php
class NotFound extends Response {
    public const STATUS_CODE = 404;
    // ^ obviously, to make this work correctly, I would need to teach swagger-php the meaning of this class and that constant
    // In theory, I should be able to support more complex cases, such as JsonAPI with similar PHP-native(or-almost) code (e.g. array shapes in PHPDoc etc).
}

In the beginning I tried subclassing attributes, hoping that a custom OA\Endpoint attribute would be able to add extra information, but for one thing, the initialized attributes are missing the context.

Then I tried implementing my own analyser (since in theory I don't need to analyse the entire codebase - I can start from my routes). But that also didn't work out - I couldn't figure out how Analysis are being merged. Then I tried to jump the whole flow and implement my own generator (and skip the step of having an analyser). Now that I think about it, it's possible a processor could have been enough (the generator approach is still a work in progress and quite painful - right now I'm stuck as to why the object hierarchy I'm generating is causing conflicting components).

Anyway, the main annoyance is perhaps lack of documentation or a clear public interface for extension (not complaining though, it is a pretty complex subject on the whole).

@uuf6429
Copy link

uuf6429 commented May 12, 2024

(the generator approach is still a work in progress and quite painful - right now I'm stuck as to why the object hierarchy I'm generating is causing conflicting components)

FYI: I've moved most of the generator code into a processor (and used the stock generator/analyser), and somehow that solved those problems.

@tobimori
Copy link
Author

Can you share your code?

@uuf6429
Copy link

uuf6429 commented May 12, 2024

Sure! I also found today that you can use Attribute classes too (they're nicer because their constructor defines parameters separately, instead of just passing an array).

Good to know: The attribute classes still extend the original annotation classes.

The 'gotcha' is that there is some sort of hack that you need to enable first, so I did that in a base processor and then I define my own processors extending the base one.

// AbstractProcessor.php

use OpenApi;

abstract class AbstractProcessor implements OpenApi\Processors\ProcessorInterface
{
    final public function __invoke(OpenApi\Analysis $analysis): void
    {
        OpenApi\Generator::$context = $analysis->context;
        try {
            $this->process($analysis);
        } finally {
            OpenApi\Generator::$context = null;
        }
    }

    abstract protected function process(OpenApi\Analysis $analysis): void;
}

// ComposerAppInfo.php

use OpenApi;
use Override;

class ComposerAppInfo extends AbstractProcessor
{
    public function __construct(
        readonly private string $composerConfigFile
    ) {
    }

    #[Override] protected function process(OpenApi\Analysis $analysis): void
    {
        $config = file_get_contents($this->composerConfigFile);
        $config = json_decode($config, false, 512, JSON_THROW_ON_ERROR);

        $analysis->addAnnotation(
            new OpenApi\Attributes\Info(                       // <-- in your case, you could have a PathItem (and child elements) here
                version: $config->version ?? null,
                description: $config->description ?? null,
                title: array_reverse(explode('/', $config->name))[0],
                contact: $config->homepage ?? null,
                license: new OpenApi\Attributes\License(
                    name: $config->license ?? null,
                ),
            ),
            $analysis->context
        );
    }
}

// Wherever you want to generate the schema (e.g. in some console command)
// (or additionally you can use the swagger-php cli with the custom processors option, but I haven't tried that yet)

$generator = new OpenApi\Generator();

$generator->setProcessors([
    new Processors\ComposerAppInfo(__DIR__ . '/../../../composer.json'),
    ...$generator->getProcessors()  // <- add back original processors
]);

$openapi = $generator->generate([__DIR__ . '/../src']);

I have some other code that defines the whole PathItem/Operations/Responses hierarchy, which seems to be what you need. It's not too difficult through the attribute classes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants