Skip to content

[3.4] Improving handling of BackedEnums with ApiResource class attributes #6298

Open
@GwendolenLynch

Description

@GwendolenLynch

To better understand where to go with #6288 I spent some time researching the history of (PHP 8.1+) enums in this library … i.e. what are the correct behavioural expectations, and any missing bits. So this issue is an attempt to bring together a bunch of context to discuss & find a clear path forward.

Right now the key questions are around how to handle \BackedEnums containing the ApiResource class attribute:

  • Have metadata generation remove operations; or
  • Add/fix support for serialization & schema generation (see example below)
    • Properties of type \BackedEnum should always normalize to the enum ->value not an iri
    • Schema properties of type \BackedEnum should always be {"enum": [value1, value2, value3, ...]} not the iri type
    • Enum resource routes return only name & value properties by default
    • cases should probably never be included as an enum item property
    • Automatic registration of item & collection GET providers

One use-case for the latter is where there is a need to provide enum metadata lookup, e.g. in the example below a human friendly description of a Status' integer.

I'm happy to try and cover json and jsonapi, and @soyuka is already working on jsonldjsonhal is probably easy for me to fit in too if needed.

Background

@soyuka's blog post API Platform 3.1 is out!

Finally after quite some time (this issue was opened in 2018!), we have support for Enums in OpenAPI, REST and GraphQL. Both Doctrine ORM and ODM have support for enums through enumType which is supported by API Platform 3.0. Alan added their support over OpenAPI and GraphQL.

How to expose Enums with API Platform — a blog post that got attention showing how to expose enums as resources. This is what I have based my assumptions/approach around here. One has to start somewhere.

Issues

Implementation
Misc
Related to Symfony Changes

PRs

Current State Examples

Note

For simplicity I'm just sticking with Accept: application/json here.

Enum as Property Value

This works and both the schema and JSON response are as expected.

Model
use ApiPlatform\Metadata\ApiResource;

#[ApiResource]
class Article
{
    public ?int $id = null;
    public ?string $title = null;
    public ?Audit $audit = null;
    public ?Status $status = null;
}

enum Audit: string
{
    case Pending = 'pending';
    case Passed = 'passed';
    case Failed = 'failed';
}

enum Status: int
{
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;
}
Schema
{
    "Article": {
        "type": "object",
        "description": "",
        "deprecated": false,
        "properties": {
            "id": {
                "readOnly": true,
                "type": "integer"
            },
            "title": {
                "type": "string"
            },
            "audit": {
                "type": "string",
                "enum": [
                    "pending",
                    "passed",
                    "failed"
                ]
            },
            "status": {
                "type": "integer",
                "enum": [0, 1, 2]
            }
        }
    }
}
curl -X 'GET' 'https://example.com/articles/1' -H 'accept: application/json'
{
  "id": 1,
  "title": "Once Upon A Title",
  "audit": "passed",
  "status": 1
}

Enum Resource as Property Value

Model
#[ApiResource]
#[GetCollection(provider: Audit::class.'::getCases')]
#[Get(provider: Audit::class.'::getCase')]
enum Audit: string
{
    use EnumApiResourceTrait;

    case Pending = 'pending';
    case Passed = 'passed';
    case Failed = 'failed';
}

#[ApiResource]
#[GetCollection(provider: Status::class.'::getCases')]
#[Get(provider: Status::class.'::getCase')]
enum Status: int
{
    use EnumApiResourceTrait;

    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;

    #[ApiProperty]
    public function getDescription(): string
    {
        return match ($this) {
            self::DRAFT => 'Article is not ready for public consumption',
            self::PUBLISHED => 'Article is publicly available',
            self::ARCHIVED => 'Article content is outdated or superseded',
        };
    }
}

use ApiPlatform\Metadata\Operation;

trait EnumApiResourceTrait
{
    public function getId(): string|int
    {
        return $this->value;
    }

    public function getValue(): int|string
    {
        return $this->value;
    }

    public static function getCases(): array
    {
        return self::cases();
    }

    public static function getCase(Operation $operation, array $uriVariables): ?BackedEnum
    {
        $id = is_numeric($uriVariables['id']) ? (int) $uriVariables['id'] : $uriVariables['id'];

        return array_reduce(self::cases(), static fn($c, \BackedEnum $case) => $case->name === $id || $case->value === $id ? $case : $c, null);
    }
}
Schema
{
    "Article": {
        "type": "object",
        "description": "",
        "deprecated": false,
        "properties": {
            "id": {
                "readOnly": true,
                "type": "integer"
            },
            "title": {
                "type": "string"
            },
            "audit": {
                "type": "string",
                "format": "iri-reference",
                "example": "https://example.com/"
            },
            "status": {
                "type": "string",
                "format": "iri-reference",
                "example": "https://example.com/"
            }
        }
    },
    "Audit": {
        "type": "object",
        "description": "",
        "deprecated": false,
        "properties": {
            "name": {
                "readOnly": true,
                "type": "string"
            },
            "value": {
                "readOnly": true,
                "allOf": [
                    {
                        "type": "string"
                    },
                    {
                        "type": "integer"
                    }
                ]
            },
            "id": {
                "readOnly": true,
                "anyOf": [
                    {
                        "type": "string"
                    },
                    {
                        "type": "integer"
                    }
                ]
            },
            "cases": {
                "readOnly": true
            }
        }
    },
    "Status": {
        "See above": "Same as Audit"
    }
}
curl -X 'GET' 'https://example.com/articles/1' -H 'accept: application/json'
{
  "id": 1,
  "title": "Once Upon A Title",
  "audit": "/audits/passed",
  "status": "/statuses/1"
}

This immediately breaks Article::status as it becomes an iri instead. Normal and correct for everything that's not an enum.

… and the enum resource routes produce "interesting" responses …

curl -X 'GET' 'https://example.com/audits/pending' -H 'accept: application/json'
Response
{
  "name": "Pending",
  "value": "pending",
  "id": "pending",
  "cases": [
    "/audits/pending",
    {
      "name": "Passed",
      "value": "passed",
      "id": "passed",
      "cases": [
        "/audits/pending",
        "/audits/passed",
        {
          "name": "Failed",
          "value": "failed",
          "id": "failed",
          "cases": [
            "/audits/pending",
            "/audits/passed",
            "/audits/failed"
          ]
        }
      ]
    },
    {
      "name": "Failed",
      "value": "failed",
      "id": "failed",
      "cases": [
        "/audits/pending",
        {
          "name": "Passed",
          "value": "passed",
          "id": "passed",
          "cases": [
            "/audits/pending",
            "/audits/passed",
            "/audits/failed"
          ]
        },
        "/audits/failed"
      ]
    }
  ]
}
curl -X 'GET' 'https://example.com/statuses/0' -H 'accept: application/json'
Response
{
  "name": "DRAFT",
  "value": 0,
  "description": "Article is not ready for public consumption",
  "id": 0,
  "cases": [
    "/statuses/0",
    {
      "name": "PUBLISHED",
      "value": 1,
      "description": "Article is publicly available",
      "id": 1,
      "cases": [
        "/statuses/0",
        "/statuses/1",
        {
          "name": "ARCHIVED",
          "value": 2,
          "description": "Article content is outdated or superseded",
          "id": 2,
          "cases": [
            "/statuses/0",
            "/statuses/1",
            "/statuses/2"
          ]
        }
      ]
    },
    {
      "name": "ARCHIVED",
      "value": 2,
      "description": "Article content is outdated or superseded",
      "id": 2,
      "cases": [
        "/statuses/0",
        {
          "name": "PUBLISHED",
          "value": 1,
          "description": "Article is publicly available",
          "id": 1,
          "cases": [
            "/statuses/0",
            "/statuses/1",
            "/statuses/2"
          ]
        },
        "/statuses/2"
      ]
    }
  ]
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions