OpenAPITools / OpenAPITools/openapi-generator

[BUG] [PHP] generator ignores oneOf null branch, treating property as non-nullable

Open
#22,185 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Issue: Bug
Dominant language
Java
Stars
26.8k
Forks
7.7k
PR merge metrics
PR metrics pending

Description

Bug Report Checklist
Description

The PHP generator ignores type: 'null' when it is declared inside a oneOf. As a result, the generated PHP model marks
the property as required and rejects null, even though the schema explicitly allows it. In my case, the
IntercomCompany.hosting_plan
property can be null in the API response, but the generated setter/validator throws because hosting_plan is treated as
non-nullable.

openapi-generator version

7.14.0 (docker image openapitools/openapi-generator-cli:v7.14.0)
Also reproduced with the latest version 7.16.0

OpenAPI declaration file content or url
components:
  schemas:
    HostingPricing:
      type: string
      enum:
        - launch
        - boost
        - supersonic
    IntercomCompany:
      type: object
      required:
        - channel_id
        - hosting_plan
      properties:
        channel_id:
          type: integer
          minimum: 1
        hosting_plan:
          oneOf:
            - $ref: '#/components/schemas/HostingPricing'
            - type: string
              enum:
                - enterprise
            - type: 'null'
Generation Details
docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli:v7.14.0 generate \
    -i /local/repro.yaml \
    -g php \
    -o /local/out

No custom config, just default PHP generator settings.

This generates the following file:

<?php
/**
 * IntercomCompany
 *
 * PHP version 8.1
 *
 * @category Class
 * @package  App\OpenApi
 * @author   OpenAPI Generator team
 * @link     https://openapi-generator.tech
 */

/**
 * Test
 *
 * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
 *
 * The version of the OpenAPI document: 1.0.0
 * Generated by: https://openapi-generator.tech
 * Generator version: 7.14.0
 */

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */

namespace App\OpenApi\Model;

use \ArrayAccess;
use \App\OpenApi\ObjectSerializer;

/**
 * IntercomCompany Class Doc Comment
 *
 * @category Class
 * @package  App\OpenApi
 * @author   OpenAPI Generator team
 * @link     https://openapi-generator.tech
 * @implements \ArrayAccess<string, mixed>
 */
class IntercomCompany implements ModelInterface, ArrayAccess, \JsonSerializable
{
    public const DISCRIMINATOR = null;

    /**
      * The original name of the model.
      *
      * @var string
      */
    protected static $openAPIModelName = 'IntercomCompany';

    /**
      * Array of property to type mappings. Used for (de)serialization
      *
      * @var string[]
      */
    protected static $openAPITypes = [
        'channel_id' => 'int',
        'hosting_plan' => '\App\OpenApi\Model\IntercomCompanyHostingPlan'
    ];

    /**
      * Array of property to format mappings. Used for (de)serialization
      *
      * @var string[]
      * @phpstan-var array<string, string|null>
      * @psalm-var array<string, string|null>
      */
    protected static $openAPIFormats = [
        'channel_id' => null,
        'hosting_plan' => null
    ];

    /**
      * Array of nullable properties. Used for (de)serialization
      *
      * @var boolean[]
      */
    protected static array $openAPINullables = [
        'channel_id' => false,
        'hosting_plan' => true
    ];

    /**
      * If a nullable field gets set to null, insert it here
      *
      * @var boolean[]
      */
    protected array $openAPINullablesSetToNull = [];

    /**
     * Array of property to type mappings. Used for (de)serialization
     *
     * @return array
     */
    public static function openAPITypes()
    {
        return self::$openAPITypes;
    }

    /**
     * Array of property to format mappings. Used for (de)serialization
     *
     * @return array
     */
    public static function openAPIFormats()
    {
        return self::$openAPIFormats;
    }

    /**
     * Array of nullable properties
     *
     * @return array
     */
    protected static function openAPINullables(): array
    {
        return self::$openAPINullables;
    }

    /**
     * Array of nullable field names deliberately set to null
     *
     * @return boolean[]
     */
    private function getOpenAPINullablesSetToNull(): array
    {
        return $this->openAPINullablesSetToNull;
    }

    /**
     * Setter - Array of nullable field names deliberately set to null
     *
     * @param boolean[] $openAPINullablesSetToNull
     */
    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
    {
        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
    }

    /**
     * Checks if a property is nullable
     *
     * @param string $property
     * @return bool
     */
    public static function isNullable(string $property): bool
    {
        return self::openAPINullables()[$property] ?? false;
    }

    /**
     * Checks if a nullable property is set to null.
     *
     * @param string $property
     * @return bool
     */
    public function isNullableSetToNull(string $property): bool
    {
        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
    }

    /**
     * Array of attributes where the key is the local name,
     * and the value is the original name
     *
     * @var string[]
     */
    protected static $attributeMap = [
        'channel_id' => 'channel_id',
        'hosting_plan' => 'hosting_plan'
    ];

    /**
     * Array of attributes to setter functions (for deserialization of responses)
     *
     * @var string[]
     */
    protected static $setters = [
        'channel_id' => 'setChannelId',
        'hosting_plan' => 'setHostingPlan'
    ];

    /**
     * Array of attributes to getter functions (for serialization of requests)
     *
     * @var string[]
     */
    protected static $getters = [
        'channel_id' => 'getChannelId',
        'hosting_plan' => 'getHostingPlan'
    ];

    /**
     * Array of attributes where the key is the local name,
     * and the value is the original name
     *
     * @return array
     */
    public static function attributeMap()
    {
        return self::$attributeMap;
    }

    /**
     * Array of attributes to setter functions (for deserialization of responses)
     *
     * @return array
     */
    public static function setters()
    {
        return self::$setters;
    }

    /**
     * Array of attributes to getter functions (for serialization of requests)
     *
     * @return array
     */
    public static function getters()
    {
        return self::$getters;
    }

    /**
     * The original name of the model.
     *
     * @return string
     */
    public function getModelName()
    {
        return self::$openAPIModelName;
    }


    /**
     * Associative array for storing property values
     *
     * @var mixed[]
     */
    protected $container = [];

    /**
     * Constructor
     *
     * @param mixed[]|null $data Associated array of property values
     *                      initializing the model
     */
    public function __construct(?array $data = null)
    {
        $this->setIfExists('channel_id', $data ?? [], null);
        $this->setIfExists('hosting_plan', $data ?? [], null);
    }

    /**
    * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
    * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
    * $this->openAPINullablesSetToNull array
    *
    * @param string $variableName
    * @param array  $fields
    * @param mixed  $defaultValue
    */
    private function setIfExists(string $variableName, array $fields, $defaultValue): void
    {
        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
            $this->openAPINullablesSetToNull[] = $variableName;
        }

        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
    }

    /**
     * Show all the invalid properties with reasons.
     *
     * @return array invalid properties with reasons
     */
    public function listInvalidProperties()
    {
        $invalidProperties = [];

        if ($this->container['channel_id'] === null) {
            $invalidProperties[] = "'channel_id' can't be null";
        }
        if (($this->container['channel_id'] < 1)) {
            $invalidProperties[] = "invalid value for 'channel_id', must be bigger than or equal to 1.";
        }

        if ($this->container['hosting_plan'] === null) {
            $invalidProperties[] = "'hosting_plan' can't be null";
        }
        return $invalidProperties;
    }

    /**
     * Validate all the properties in the model
     * return true if all passed
     *
     * @return bool True if all properties are valid
     */
    public function valid()
    {
        return count($this->listInvalidProperties()) === 0;
    }


    /**
     * Gets channel_id
     *
     * @return int
     */
    public function getChannelId()
    {
        return $this->container['channel_id'];
    }

    /**
     * Sets channel_id
     *
     * @param int $channel_id channel_id
     *
     * @return self
     */
    public function setChannelId($channel_id)
    {
        if (is_null($channel_id)) {
            throw new \InvalidArgumentException('non-nullable channel_id cannot be null');
        }

        if (($channel_id < 1)) {
            throw new \InvalidArgumentException('invalid value for $channel_id when calling IntercomCompany., must be bigger than or equal to 1.');
        }

        $this->container['channel_id'] = $channel_id;

        return $this;
    }

    /**
     * Gets hosting_plan
     *
     * @return \App\OpenApi\Model\IntercomCompanyHostingPlan
     */
    public function getHostingPlan()
    {
        return $this->container['hosting_plan'];
    }

    /**
     * Sets hosting_plan
     *
     * @param \App\OpenApi\Model\IntercomCompanyHostingPlan $hosting_plan hosting_plan
     *
     * @return self
     */
    public function setHostingPlan($hosting_plan)
    {
        if (is_null($hosting_plan)) {
            array_push($this->openAPINullablesSetToNull, 'hosting_plan');
        } else {
            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
            $index = array_search('hosting_plan', $nullablesSetToNull);
            if ($index !== FALSE) {
                unset($nullablesSetToNull[$index]);
                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
            }
        }
        $this->container['hosting_plan'] = $hosting_plan;

        return $this;
    }
    /**
     * Returns true if offset exists. False otherwise.
     *
     * @param integer $offset Offset
     *
     * @return boolean
     */
    public function offsetExists($offset): bool
    {
        return isset($this->container[$offset]);
    }

    /**
     * Gets offset.
     *
     * @param integer $offset Offset
     *
     * @return mixed|null
     */
    #[\ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        return $this->container[$offset] ?? null;
    }

    /**
     * Sets value based on offset.
     *
     * @param int|null $offset Offset
     * @param mixed    $value  Value to be set
     *
     * @return void
     */
    public function offsetSet($offset, $value): void
    {
        if (is_null($offset)) {
            $this->container[] = $value;
        } else {
            $this->container[$offset] = $value;
        }
    }

    /**
     * Unsets offset.
     *
     * @param integer $offset Offset
     *
     * @return void
     */
    public function offsetUnset($offset): void
    {
        unset($this->container[$offset]);
    }

    /**
     * Serializes the object to a value that can be serialized natively by json_encode().
     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
     *
     * @return mixed Returns data which can be serialized by json_encode(), which is a value
     * of any type other than a resource.
     */
    #[\ReturnTypeWillChange]
    public function jsonSerialize()
    {
       return ObjectSerializer::sanitizeForSerialization($this);
    }

    /**
     * Gets the string presentation of the object
     *
     * @return string
     */
    public function __toString()
    {
        return json_encode(
            ObjectSerializer::sanitizeForSerialization($this),
            JSON_PRETTY_PRINT
        );
    }

    /**
     * Gets a header-safe presentation of the object
     *
     * @return string
     */
    public function toHeaderValue()
    {
        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
    }
}

Which contains:

        if ($this->container['hosting_plan'] === null) {
            $invalidProperties[] = "'hosting_plan' can't be null";
        }
Steps to reproduce
  1. Use the spec above (repro.yaml) and run the generation command shown.
  2. Inspect the generated Model/IntercomCompany.php.
  3. Note that listInvalidProperties() contains "'hosting_plan' can't be null" and the setter throws when null is given.
  4. Try to instantiate the model with ['channel_id' => 1, 'hosting_plan' => null] and call valid(). It returns false
    because hosting_plan is treated as non-nullable.
Related issues/PRs

I couldn’t find an existing issue covering oneOf with type: 'null' in the PHP generator.

Suggest a fix

When a schema contains a oneOf (or anyOf) branch with type: 'null', the generator should mark the property as nullable
in the PHP model (openAPINullables['hosting_plan'] = true) and skip the 'hosting_plan' can't be null validation. The
same applies to
other generators that rely on similar logic.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running the provided Docker command with repro.yaml and inspect the generated IntercomCompany model, especially hosting_plan validation and setter behavior. Trace the PHP generator's handling of oneOf branches containing type: 'null'. Done means a required hosting_plan accepts null when the schema includes that branch, without rejecting valid API responses.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, php
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.