kenspirit / joi-to-json

Capable of converting different versions' joi object to json schema
MIT License
39 stars 18 forks source link

Add support for Joi key relationship options (`.or()`, `.xor()`, `.oxor()` `.and()`, & `.nand()`) #38

Closed hobgoblina closed 2 years ago

hobgoblina commented 2 years ago

It looks like these Joi object schema options aren't supported at the moment, but they all should be implementable via:

kenspirit commented 2 years ago

Yes, it's currently not supported yet. The oneOf, anyOf keywords in JSON schema are schema-based, while the or, xor, and in Joi are referring to the key relationship in the object. Different key combinations of object must be extracted.

Say a Joi definition:

const schema = Joi.object({
    a: Joi.string(),
    b: Joi.number()
}).or('a', 'b');

need to be converted to:

{
  anyOf: [
    { type: "object", properties: { a: { type: "string" } } },
    { type: "object", properties: { b: { type: "number" } } },
    { type: "object", properties: { a: { type: "string" }, b: { type: "number" } } }
  ]
}

The more keys the object schema contains and or includes, the more combination needs to be generated. I am not sure if this is a good approach or if there is a better one.

Idea? Would you like to submit a PR?

hobgoblina commented 2 years ago

One potential part of the solution... rather than replicating the entire object schema, you could reference the object's keys using required and not. So for the above schema you could do:

{
  type: "object",
  oneOf: [
    { required: [ "a" ] },
    { required: [ "b" ] },
  ],
  properties: { a: { type: "string" }, b: { type: "number" } }
}

or, if it was a xor:

{
type: "object",
  oneOf: [
    { 
      required: [ "a" ], 
      not: { required: [ "b" ] }
    },
    { 
      required: [ "b" ] ,
      not: { required: [ "a" ] }
    }
  ],
  properties: { a: { type: "string" }, b: { type: "number" } }
}

Because not: { required: [] } results in "not allowed" rather than "not required".

so, for oxor (one extra option):

{
type: "object",
  oneOf: [
    { 
      required: [ "a" ], 
      not: { required: [ "b" ]}
    },
    { 
      required: [ "b" ] ,
      not: { required: [ "a" ]}
    },
    { not: { required: [ "a", "b" ] } }
  ],
  properties: { a: { type: "string" }, b: { type: "number" } }
}

and nand (should only need 2 options no matter the number of keys):

{
type: "object",
  oneOf: [
    { not: { required: [ "a", "b" ] } },
    { not: { required: [] } }
  ],
  properties: { a: { type: "string" }, b: { type: "number" } }
}

and would just require the one option with all keys required.

hobgoblina commented 2 years ago

You're still effectively generating a schema object for each key for most of them, but much reduced compared to generating a bunch of full deeply-nested schema with various differences between each.

It might get a bit more complicated tho when combining multiple key relationship constraints. I'll try to look into that a bit this week, but I think it could just be a matter of iterating through and recreating the current set for each of the current interation's constraint options.

Then, the total number of oneOf entries would be the product of the sizes of each key constraint's oneOf entries - ie, 2 or or xor constraints with 2 keys each = 4 entries ... 2 oxor with 3 keys each + a nand = 32 entries (4*4*2), etc.

Not sure when I'll be able to dedicate some time to familiarizing myself with your code + joi's parser, but I'd be happy to check back here when I find a bit of time for it, if you or somebody else doesn't get to it first.

kenspirit commented 2 years ago

No hurry for implementation. I think what we need to do is to come up the working JSON representation (As concise as possible and possibly different format for different JSON spec version).

For the or case, your proposal actually does not work as oneOf only valid WHEN ONLY ONE is matched. But the joi or accepts both a and b exists scenario. (If you try https://www.jsonschemavalidator.net/, you can see that the { a:'1', b:2}) failed for your schema but not the Joi definition.

Adding one option to your xor definition actually works for or:

{
  type: "object",
  oneOf: [
    { 
      required: [ "a" ], 
      not: { required: [ "b" ] }
    },
    { 
      required: [ "b" ] ,
      not: { required: [ "a" ] }
    },
    { 
      required: [ "a", "b" ]
    }
  ],
  properties: { a: { type: "string" }, b: { type: "number" } }
}

Your oxor does not work when validating against { a: '1' } because it matches the first and third option. I do not have idea on how to construct the json schema yet.

I think below is good enough for nand?

{
  type: "object",
  not: { required: [ "a", "b" ] },
  properties: { a: { type: "string" }, b: { type: "number" } }
}
hobgoblina commented 2 years ago

Ah good catch, I hadn't tested a couple of those lol.

The oxor one has been working for a schema I've been using tho, using json schema draft 7. { a: '1' } shouldn't match the 3rd option as that option should result in "both a & b are individually forbidden".

And yeah, your nand should work. I hadn't tested wither you could have a not: { required } alongside a oneOf that contains other not: { required } options, but it's working for me. :)

kenspirit commented 2 years ago

{ a: '1' } shouldn't match the 3rd option as that option should result in "both a & b are individually forbidden".

I think the 3rd option means "both a & b are not required at the same time"?

image

hobgoblina commented 2 years ago

Hmm, yeah the schema I wrote with that xor pattern fails in that schema validator with the same errors. Looks like vscode's schema validator just isn't throwing those errors. :P I would've thought { not: { required: [ "a", "b" ] } } would only be satisfied if both keys are absent, but maybe not...

kenspirit commented 2 years ago

@necropolina, I have managed to write Joi and JSON schema for different cases. Hopefully I have not missed any case. additionalProperties should support either true or false.

// At least one of a or b or c exists.
// Failed on { d: null }
const orJoi = joi.object({
  a: joi.string(),
  b: joi.number(),
  c: joi.boolean(),
  d: joi.any().valid(null)
}).or('a', 'b', 'c');

const orSchemaGeneral = {
  type: 'object',
  anyOf: [
    { required: ["a"] }, { required: ["b"] }, { required: ["c"] }
  ],
  properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" }, d: { type: "null" } },
  additionalProperties: false
}

// Either a, b, and c All NOT exists, or all of them exists
// Failed on { a: 'hi', b: 1 }
const andJoi = joi.object({
  a: joi.string(),
  b: joi.number(),
  c: joi.boolean(),
  d: joi.any().valid(null)
}).and('a', 'b', 'c');

const andSchemaGeneral = {
  type: "object",
  oneOf: [
    {
      allOf: [
        {
          not: { required: ["a"] }
        },
        {
          not: { required: ["b"] }
        },
        {
          not: { required: ["c"] }
        }
      ]
    },
    { required: ["a", "b", "c"] }
  ],
  properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" }, d: { type: "null" } },
  additionalProperties: false
};

// a, b, and c cannot all exist at the same time
// Failed on { a: 'hi', b: 1, c: true }
const nandJoi = joi.object({
  a: joi.string(),
  b: joi.number(),
  c: joi.boolean(),
  d: joi.any().valid(null)
}).nand('a', 'b', 'c');

const nandSchemaGeneral = {
  type: "object",
  not: { required: ["a", "b", "c"] },
  properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" }, d: { type: "null" } },
  additionalProperties: false
};

// Only one of a, b and c can and must exist
// Failed on { d: null } or { a: 'hi', b: 1 }
const xorJoi = joi.object({
  a: joi.string(),
  b: joi.number(),
  c: joi.boolean(),
  d: joi.any().valid(null)
}).xor('a', 'b', 'c');

const xorSchemaDraft7 = {
  type: "object",
  if: { propertyNames: { enum: ["a", "b", "c"] }, minProperties: 2 },
  then: false,
  else: {
    oneOf: [
      {
        required: ["a"]
      },
      {
        required: ["b"]
      },
      {
        required: ["c"]
      }
    ]
  },
  properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" }, d: { type: "null" } },
  additionalProperties: false
};

// Only one of a, b and c can exist
// Failed on { a: 'hi', b: 1 }
const oxorJoi = joi.object({
  a: joi.string(),
  b: joi.number(),
  c: joi.boolean(),
  d: joi.any().valid(null)
}).oxor('a', 'b', 'c');

const oxorSchemaGeneral = {
  type: "object",,
  oneOf: [
    { required: ['a'] },
    { required: ['b'] },
    { required: ['c'] },
    {
      not: {
        oneOf: [
          { required: ['a'] },
          { required: ['b'] },
          { required: ['c'] },
          { required: ['a', 'b'] },
          { required: ['a', 'c'] },
          { required: ['b', 'c'] }
        ]
      }
    }
  ],
  properties: { a: { type: "string" }, b: { type: "number" }, c: { type: "boolean" }, d: { type: "null" } },
  additionalProperties: false
};
kenspirit commented 2 years ago

Version 3.0.0 after this commit supports these relation operators.

hobgoblina commented 2 years ago

Awesome stuff, thanks so much for working on this!