Skip to content

Extend generator to support translating oneOf to xor and implementing, also better support for anyOf and Union#30

Merged
sbilstein merged 6 commits intomainfrom
extend-generator
Jul 28, 2025
Merged

Extend generator to support translating oneOf to xor and implementing, also better support for anyOf and Union#30
sbilstein merged 6 commits intomainfrom
extend-generator

Conversation

@sbilstein
Copy link

@sbilstein sbilstein commented Jul 24, 2025

In order for this to work in Platform, the hbs files in apps/platform need to be updated, which is something I have in a separate branch

Against my better judgement i wanted to support OpenAPI's oneOf with a discriminator to enable a multi-type json payload REST endpoint for PgBoss.

Our validation code treated oneOf just like anyOf but both oneOf and anyOf were behaving like allOf. Essentially, all the validations for every type were being run.

Previously allOf mapped to composite and anyOf and oneOf mapped to union. I introduced xor for oneOf. I also ammended the parsing structure to capture the discriminator object in a flattened, handlerbars friendly way.

I deliberately chose to avoid making changes to validation.expression.hbs and so in order to handle those cases, I hoisted the decision making on parsing those expressions to validation.declaration.hbs which now delegates to single union or xor.discriminant variants.

xor and union are functionally the same since it is possible for oneOf to map to two differently named but identical schemas that would both pass validation. The behavior is somewhat ill-defined, so I let it use the same formula as oneOf, it greedily checks to see if there is one validation that passes with no errors, and if so it early returns. If no schemas match, all errors are returned joined.

for xor.discriminator you will see logic that builds a map of validators and uses the property name to run the correct validator for for the object. It will only return errors for the specific validator.

This is what using this looks like in YAML.

  '/multiple-bodies-any-of':
    post:
      tags:
        - user
      summary: Multiple bodies
      description: ''
      operationId: multipleBodies
      requestBody:
        content:
          application/json:
            schema:
              anyOf:
                - $ref: '#/components/schemas/BaseComponent'
                - $ref: '#/components/schemas/InheritedComponent'
      responses:
        '201':
          description: successful operation
          content:
            application/json:
              schema: {}
  '/multiple-bodies':
    post:
      tags:
        - user
      summary: Multiple bodies
      description: ''
      operationId: multipleBodies
      requestBody:
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/BaseComponent'
                - $ref: '#/components/schemas/InheritedComponent'
      responses:
        '201':
          description: successful operation
          content:
            application/json:
              schema: {}
  '/multiple-bodies-with-discriminator':
    post:
      tags:
        - user
      summary: Multiple bodies
      description: ''
      operationId: multipleBodiesWithDiscriminator
      requestBody:
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/BaseComponent'
                - $ref: '#/components/schemas/InheritedComponent'
              discriminator:
                propertyName: type
                mapping:
                  base: '#/components/schemas/BaseComponent'
                  inherited: '#/components/schemas/InheritedComponent'
      responses:
        '201':
          description: successful operation
          content:
            application/json:
              schema: {}

This is the validator output:


export const multipleBodiesRequestObjectValidator: utilities.ValidatorFn = (inputToValidate: any): utilities.ValidatorResponse => {
  const validatorArray = new Array<utilities.ValidatorFn>();
  const validatorErrorArray = new Array<Map<string, string | string[]>>();
  const allowedPropertiesArray = new Array<string[]>();

  validatorArray.push(inputToValidate => {
    let validationErrors = new Map<string, string | string[]>();
    let allowedProperties = new Array<string>();

    {
      const validatorResponse = BaseComponentModelValidator(inputToValidate);
      validationErrors = new Map<string, string | string[]>([...validationErrors, ...validatorResponse.validationErrors]);
      allowedProperties.push(...validatorResponse.allowedProperties);
    }

    validatorErrorArray.push(validationErrors);
    allowedPropertiesArray.push(allowedProperties);
  });

  validatorArray.push(inputToValidate => {
    let validationErrors = new Map<string, string | string[]>();
    let allowedProperties = new Array<string>();

    {
      const validatorResponse = InheritedComponentModelValidator(inputToValidate);
      validationErrors = new Map<string, string | string[]>([...validationErrors, ...validatorResponse.validationErrors]);
      allowedProperties.push(...validatorResponse.allowedProperties);
    }

    validatorErrorArray.push(validationErrors);
    allowedPropertiesArray.push(allowedProperties);
  });

  for (let i = 0; i < validatorArray.length; ++i) {
    const validatorResponse = validatorArray[i](inputToValidate);
    if (validatorResponse.validationErrors.size === 0) {
      return validatorResponse;
    }
  }
  // merge all errors and allowedProperties
  const mergedValidationErrors = new Map<string, string | string[]>();
  const mergedAllowedProperties: string[] = [];

  for (let i = 0; i < validatorArray.length; ++i) {
    const validatorResponse = validatorArray[i](inputToValidate);
    // Early return if any validator has no errors
    if (validatorResponse.validationErrors.size === 0) {
      return { validationErrors: validatorResponse.validationErrors, allowedProperties: validatorResponse.allowedProperties };
    }

    for (const [key, value] of validatorResponse.validationErrors.entries()) {
      if (mergedValidationErrors.has(key)) {
        // Merge error messages for the same key
        const existing = mergedValidationErrors.get(key);
        if (Array.isArray(existing)) {
          mergedValidationErrors.set(key, existing.concat(value));
        } else if (existing !== undefined) {
          mergedValidationErrors.set(key, [existing, value].flat());
        }
      } else {
        mergedValidationErrors.set(key, value);
      }
    }
    mergedAllowedProperties.push(...validatorResponse.allowedProperties);
  }
  return {
    validationErrors: mergedValidationErrors,
    allowedProperties: mergedAllowedProperties,
  };
};
type multipleBodiesWithDiscriminatorRequestObjectValidatorPropertyName = 'base' | 'inherited';

export const multipleBodiesWithDiscriminatorRequestObjectValidator: utilities.ValidatorFn = (inputToValidate: any): utilities.ValidatorResponse => {
  const validatorMap = new Map<string, utilities.ValidatorFn>();
  const validatorErrorMap = new Map<multipleBodiesWithDiscriminatorRequestObjectValidatorPropertyName, Map<string, string | string[]>>();
  const allowedPropertiesMap = new Map<multipleBodiesWithDiscriminatorRequestObjectValidatorPropertyName, string[]>();

  const discriminatorPropertyName = 'type';
  validatorMap.set('base', inputToValidate => {
    let validationErrors = new Map<string, string | string[]>();
    let allowedProperties = new Array<string>();

    {
      const validatorResponse = BaseComponentModelValidator(inputToValidate);
      validationErrors = new Map<string, string | string[]>([...validationErrors, ...validatorResponse.validationErrors]);
      allowedProperties.push(...validatorResponse.allowedProperties);
    }

    validatorErrorMap.set('base', new Map<string, string | string[]>(validatorResponse.validationErrors));
    allowedPropertiesMap.set('base', validatorResponse.allowedProperties);
  });

  validatorMap.set('inherited', inputToValidate => {
    let validationErrors = new Map<string, string | string[]>();
    let allowedProperties = new Array<string>();

    {
      const validatorResponse = InheritedComponentModelValidator(inputToValidate);
      validationErrors = new Map<string, string | string[]>([...validationErrors, ...validatorResponse.validationErrors]);
      allowedProperties.push(...validatorResponse.allowedProperties);
    }

    validatorErrorMap.set('inherited', new Map<string, string | string[]>(validatorResponse.validationErrors));
    allowedPropertiesMap.set('inherited', validatorResponse.allowedProperties);
  });

  const discriminatorValue = inputToValidate[discriminatorPropertyName];
  const validatorResponse = validatorMap.get(discriminatorValue)(inputToValidate);
  return {
    validationErrors: validatorResponse.validationErrors,
    allowedProperties: validatorResponse.allowedProperties,
  };
};

@sonarqubecloud
Copy link

@sbilstein sbilstein marked this pull request as ready for review July 28, 2025 14:26
@sbilstein sbilstein merged commit cc57a15 into main Jul 28, 2025
7 checks passed
@sbilstein sbilstein deleted the extend-generator branch July 28, 2025 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants