Skip to content

optic capture failing due to discriminated union represented as oneOf with enums #2656

@sennyeya

Description

@sennyeya

Describe the bug
I'm trying to write a new spec for a Backstage plugin that uses discriminated union types rendered as oneOf. I'm getting an AJV validationg error where Optic is using AJV to validate its own patched schema, which I think means that the patched schema is invalid. All of the code that I outline below can be found in this PR, here. Basically, I'm trying to use oneOf and enums to differentiate between two separate types. From what I can tell with json-schema-to-ts, my spec is correct, but I'm getting a few weird errors from AJV surfaced through Optic,

  1. enum contains multiple values that are the same
  2. value that shouldn't be an array is not an array
  3. value doesn't match schema from anyOf
    Full error messages below.

The types in question are,

type ConditionalPolicyDecision = {
  result: AuthorizeResult.CONDITIONAL;
  pluginId: string;
  resourceType: string;
  conditions: PermissionCriteria<PermissionCondition>;
};

type DefinitivePolicyDecision = {
  result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}

export type PolicyDecision =
  | DefinitivePolicyDecision
  | ConditionalPolicyDecision;

So far, my JSON schema (YAML) looks like this,

  DefinitivePolicyDecision:
      type: object
      properties:
        result:
          type: string
          enum:
            - ALLOW
            - DENY
        id:
          type: string
      required:
        - result
        - id
      additionalProperties: false

    ConditionalPolicyDecision:
      type: object
      properties:
        result:
          type: string
          enum:
            - CONDITIONAL
        pluginId:
          type: string
        resourceType:
          type: string
        conditions:
          $ref: '#/components/schemas/PermissionCriteria'
        id:
          type: string
      required:
        - result
        - id
        - pluginId
        - resourceType
        - conditions
      additionalProperties: true

    PolicyDecision:
      oneOf:
        - $ref: '#/components/schemas/ConditionalPolicyDecision'
        - $ref: '#/components/schemas/DefinitivePolicyDecision'

This results in the following schema (added a console.dir here),

const schema: SchemaObject = JSON.parse(JSON.stringify(input));

{
  type: 'object',
  properties: {
    items: {
      type: 'array',
      items: {
        oneOf: [
          {
            type: 'object',
            properties: {
              result: {
                type: 'string',
                enum: [ 'CONDITIONAL', 'ALLOW', 'ALLOW', 'DENY', 'DENY' ]
              },
              pluginId: { type: 'string' },
              resourceType: { type: 'string' },
              conditions: {
                oneOf: [
                  {
                    type: 'object',
                    properties: {
                      allOf: {
                        type: 'array',
                        items: {
                          type: 'object',
                          properties: {
                            resourceType: { type: 'string' },
                            rule: { type: 'string' },
                            params: {
                              type: 'object',
                              additionalProperties: {
                                oneOf: [
                                  {
                                    oneOf: [
                                      { type: 'string' },
                                      { type: 'number' },
                                      {
                                        type: 'boolean',
                                        nullable: true
                                      }
                                    ]
                                  },
                                  {
                                    type: 'array',
                                    items: {
                                      oneOf: [
                                        { type: 'string' },
                                        { type: 'number' },
                                        {
                                          type: 'boolean',
                                          nullable: true
                                        }
                                      ]
                                    }
                                  }
                                ]
                              }
                            }
                          },
                          required: [ 'resourceType', 'rule' ],
                          additionalProperties: true
                        },
                        minItems: 1
                      }
                    },
                    additionalProperties: true
                  },
                  {
                    type: 'object',
                    properties: {
                      anyOf: {
                        type: 'array',
                        items: {
                          type: 'object',
                          properties: {
                            resourceType: { type: 'string' },
                            rule: { type: 'string' },
                            params: {
                              type: 'object',
                              additionalProperties: {
                                oneOf: [
                                  {
                                    oneOf: [
                                      { type: 'string' },
                                      { type: 'number' },
                                      {
                                        type: 'boolean',
                                        nullable: true
                                      }
                                    ]
                                  },
                                  {
                                    type: 'array',
                                    items: {
                                      oneOf: [
                                        { type: 'string' },
                                        { type: 'number' },
                                        {
                                          type: 'boolean',
                                          nullable: true
                                        }
                                      ]
                                    }
                                  }
                                ]
                              }
                            }
                          },
                          required: [ 'resourceType', 'rule' ],
                          additionalProperties: true
                        },
                        minItems: 1
                      }
                    },
                    additionalProperties: true
                  },
                  {
                    type: 'object',
                    properties: {
                      not: {
                        type: 'array',
                        items: {
                          type: 'object',
                          properties: {
                            resourceType: { type: 'string' },
                            rule: { type: 'string' },
                            params: {
                              type: 'object',
                              additionalProperties: {
                                oneOf: [
                                  {
                                    oneOf: [
                                      { type: 'string' },
                                      { type: 'number' },
                                      {
                                        type: 'boolean',
                                        nullable: true
                                      }
                                    ]
                                  },
                                  {
                                    type: 'array',
                                    items: {
                                      oneOf: [
                                        { type: 'string' },
                                        { type: 'number' },
                                        {
                                          type: 'boolean',
                                          nullable: true
                                        }
                                      ]
                                    }
                                  }
                                ]
                              }
                            }
                          },
                          required: [ 'resourceType', 'rule' ],
                          additionalProperties: true
                        },
                        minItems: 1
                      }
                    },
                    additionalProperties: true
                  },
                  {
                    type: 'object',
                    properties: {
                      resourceType: { type: 'string' },
                      rule: { type: 'string' },
                      params: {
                        type: 'object',
                        additionalProperties: {
                          oneOf: [
                            {
                              oneOf: [
                                { type: 'string' },
                                { type: 'number' },
                                { type: 'boolean', nullable: true }
                              ]
                            },
                            {
                              type: 'array',
                              items: {
                                oneOf: [
                                  { type: 'string' },
                                  { type: 'number' },
                                  { type: 'boolean', nullable: true }
                                ]
                              }
                            }
                          ]
                        }
                      }
                    },
                    required: [ 'resourceType', 'rule' ],
                    additionalProperties: true
                  }
                ]
              },
              id: { type: 'string' }
            },
            required: [ 'result', 'id' ],
            additionalProperties: true
          },
          {
            type: 'object',
            properties: {
              result: {
                type: 'string',
                enum: [ 'ALLOW', 'DENY', 'CONDITIONAL' ]
              },
              id: { type: 'string' },
              pluginId: { type: 'string' },
              resourceType: { type: 'string' },
              conditions: {
                type: 'object',
                properties: {
                  rule: { type: 'string' },
                  params: { type: 'array', items: { type: 'string' } }
                },
                required: [ 'rule', 'params' ]
              }
            },
            required: [ 'result', 'id' ],
            additionalProperties: false
          }
        ]
      }
    }
  },
  required: [ 'items' ],
  additionalProperties: false
}

which throws an error during AJV compilation,

Error: schema is invalid: data/properties/items/items/oneOf/0/properties/result/enum must NOT have duplicate items (items ## 3 and 4 are identical), data/properties/items/items must be array, data/properties/items/items must match a schema in anyOf
    at Ajv.validateSchema (/Users/sennyeya/.asdf/installs/nodejs/19.0.1/lib/node_modules/@useoptic/optic/node_modules/ajv/dist/core.js:266:23)
    at Ajv._addSchema (/Users/sennyeya/.asdf/installs/nodejs/19.0.1/lib/node_modules/@useoptic/optic/node_modules/ajv/dist/core.js:460:18)
    at Ajv.compile (/Users/sennyeya/.asdf/installs/nodejs/19.0.1/lib/node_modules/@useoptic/optic/node_modules/ajv/dist/core.js:158:26)
    at ShapeDiffTraverser.traverse
...

Taking a look at the logs, this request causes part of the enum error by adding the "ALLOW" and "DENY" values twice. The other 2 errors I'm not sure about.

{
  request: {
    host: 'localhost:8001',
    method: 'post',
    path: '/authorize',
    body: {
      contentType: 'application/json',
      body: '{"items":[{"id":"123","permission":{"type":"resource","name":"test.permission.1","resourceType":"test-resource-1","attributes":{}},"resourceRef":"resource:1"},{"id":"234","permission":{"type":"resource","name":"test.permission.2","resourceType":"test-resource-2","attributes":{}},"resourceRef":"resource:2"},{"id":"345","permission":{"type":"resource","name":"test.permission.3","resourceType":"test-resource-1","attributes":{}},"resourceRef":"resource:3"},{"id":"456","permission":{"type":"resource","name":"test.permission.4","resourceType":"test-resource-2","attributes":{}},"resourceRef":"resource:4"}]}',
      size: 607
    },
    headers: [],
    query: []
  },
  response: {
    statusCode: '200',
    body: {
      contentType: 'application/json; charset=utf-8',
      body: '{"items":[{"id":"123","result":"ALLOW"},{"id":"234","result":"ALLOW"},{"id":"345","result":"DENY"},{"id":"456","result":"DENY"}]}',
      size: 129
    },
    headers: []
  }
}

Internal optic schema diff after running the above request,

--- old.json	2024-01-11 16:39:58
+++ new.json	2024-01-11 16:01:40
@@ -8,7 +8,10 @@
           {
             "type": "object",
             "properties": {
-              "result": { "type": "string", "enum": ["CONDITIONAL"] },
+              "result": {
+                "type": "string",
+                "enum": ["CONDITIONAL", "ALLOW", "ALLOW", "DENY", "DENY"]
+              },
               "pluginId": { "type": "string" },
               "resourceType": { "type": "string" },
               "conditions": {
@@ -179,13 +182,7 @@
               },
               "id": { "type": "string" }
             },
-            "required": [
-              "result",
-              "id",
-              "pluginId",
-              "resourceType",
-              "conditions"
-            ],
+            "required": ["result", "id"],
             "additionalProperties": true
           },
           {
@@ -207,13 +204,7 @@
                 "required": ["rule", "params"]
               }
             },
-            "required": [
-              "result",
-              "id",
-              "pluginId",
-              "resourceType",
-              "conditions"
-            ],
+            "required": ["result", "id"],
             "additionalProperties": false
           }
         ]

To Reproduce
See my PR here. I'm running PORT=8001 optic capture src/schema/openapi.yaml --server-override http://localhost:8001 in plugins/permission-backend to get the output seen above.

Expected behavior
An error is not thrown and the schema validates as expected.

Details (please complete the following information):

  • OS and version: [e.g. Mac OS 13.1] MacOS 13.0.1
  • Optic version: [e.g. v0.37.1] 0.52 and 0.53.20
  • NodeJS version: [e.g. 18.0.0] 19.0.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions