diff --git a/.nvmrc b/.nvmrc index ceeec56..521af05 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.13.0 +v12.14.0 diff --git a/.travis.yml b/.travis.yml index 322149a..1f864a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,6 @@ language: node_js node_js: - "13" -- "11" +- "12 - "10" - "8" diff --git a/README.md b/README.md index 010aeda..8c258bc 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,16 @@ const schema = S.object() .enum(Object.values(ROLES)) .default(ROLES.USER) ) + .prop( + 'birthday', + S.raw({ type: 'string', format: 'date', formatMaximum: '2020-01-01' }) // formatMaximum is an AJV custom keywords + ) .definition( 'address', S.object() .id('#address') - .prop('line1', S.string()) - .prop('line2', S.anyOf([S.string(), S.null()])) + .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable + .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable .prop('country', S.string()) .prop('city', S.string()) .prop('zipcode', S.string()) @@ -81,9 +85,6 @@ Schema generated: "$id": "#address", "properties": { "line1": { - "type": "string" - }, - "line2": { "anyOf": [ { "type": "string" @@ -93,6 +94,10 @@ Schema generated: } ] }, + "line2": { + "type": "string", + "nullable": true + }, "country": { "type": "string" }, @@ -119,10 +124,15 @@ Schema generated: "type": "string", "minLength": 8 }, + "birthday": { + "type": "string", + "format": "date", + "formatMaximum": "2020-01-01" + }, "role": { + "type": "string", "enum": ["ADMIN", "USER"], - "default": "USER", - "type": "string" + "default": "USER" }, "address": { "$ref": "#address" diff --git a/docs/API.md b/docs/API.md index 8ef8075..17cc746 100644 --- a/docs/API.md +++ b/docs/API.md @@ -107,6 +107,15 @@ validation succeeds against this keyword if the instance also successfully valid

When "if" is present, and the instance fails to validate against its subschema, then validation succeeds against this keyword if the instance successfully validates against this keyword's subschema.

+
raw(fragment)BaseSchema
+

Because the differences between JSON Schemas and Open API (Swagger) +it can be handy to arbitrary modify the schema injecting a fragment

+ +
valueOf()object

It returns all the schema values

@@ -147,6 +156,15 @@ then validation succeeds against this keyword if the instance successfully valid
mixed(types)MixedSchema

A mixed schema is the union of multiple types (e.g. ['string', 'integer']

+
raw(fragment)BaseSchema
+

Because the differences between JSON Schemas and Open API (Swagger) +it can be handy to arbitrary modify the schema injecting a fragment

+ +
IntegerSchema([options])NumberSchema

Represents a NumberSchema.

@@ -225,6 +243,9 @@ Note the property name that the schema is testing will always be a string.

There are no restrictions placed on the values within the array.

https://json-schema.org/latest/json-schema-validation.html#rfc.section.9

+
RawSchema(schema)FluentSchema
+

Represents a raw JSON Schema that will be parsed

+
StringSchema([options])StringSchema

Represents a StringSchema.

@@ -589,6 +610,24 @@ then validation succeeds against this keyword if the instance successfully valid | thenClause | [BaseSchema](#BaseSchema) | | | elseClause | [BaseSchema](#BaseSchema) | [https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.6.1](reference) | + + +## raw(fragment) ⇒ [BaseSchema](#BaseSchema) + +Because the differences between JSON Schemas and Open API (Swagger) +it can be handy to arbitrary modify the schema injecting a fragment + +- Examples: + +* S.number().raw({ nullable:true }) +* S.string().format('date').raw({ formatMaximum: '2020-01-01' }) + +**Kind**: global function + +| Param | Type | Description | +| -------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| fragment | [string](#string) | an arbitrary JSON Schema to inject [https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.3.3](reference) | + ## valueOf() ⇒ [object](#object) @@ -699,6 +738,24 @@ A mixed schema is the union of multiple types (e.g. ['string', 'integer'] | ----- | ------------------------------------------------------------ | | types | [[ 'Array' ].<string>](#string) | + + +## raw(fragment) ⇒ [BaseSchema](#BaseSchema) + +Because the differences between JSON Schemas and Open API (Swagger) +it can be handy to arbitrary modify the schema injecting a fragment + +- Examples: + +* S.raw({ nullable:true, format: 'date', formatMaximum: '2020-01-01' }) +* S.string().format('date').raw({ formatMaximum: '2020-01-01' }) + +**Kind**: global function + +| Param | Type | Description | +| -------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| fragment | [string](#string) | an arbitrary JSON Schema to inject [https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.3.3](reference) | + ## IntegerSchema([options]) ⇒ [NumberSchema](#NumberSchema) @@ -947,6 +1004,18 @@ There are no restrictions placed on the values within the array. | name | [string](#string) | | props | FluentSchema | + + +## RawSchema(schema) ⇒ FluentSchema + +Represents a raw JSON Schema that will be parsed + +**Kind**: global function + +| Param | Type | +| ------ | ------------------- | +| schema | Object | + ## StringSchema([options]) ⇒ [StringSchema](#StringSchema) diff --git a/package.json b/package.json index 0372e47..323898e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "pre-commit": "lint-staged" } }, + "jest": { + "coverageReporters": ["text", "lcovonly"] + }, + "lint-staged": { "linters": { "*.{json,md,js,ts}": [ @@ -54,7 +58,8 @@ "doc": "jsdoc2md ./src/*.js > docs/API.md" }, "devDependencies": { - "ajv": "^6.5.5", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", "husky": "^1.1.3", "jest": "^24.3.1", "jsdoc-to-markdown": "^4.0.1", diff --git a/src/ArraySchema.test.js b/src/ArraySchema.test.js index 67ef764..a854338 100644 --- a/src/ArraySchema.test.js +++ b/src/ArraySchema.test.js @@ -165,5 +165,18 @@ describe('ArraySchema', () => { ).toThrow("'maxItems' must be a integer") }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = ArraySchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'array', + customKeyword: true, + }) + }) + }) }) }) diff --git a/src/BaseSchema.js b/src/BaseSchema.js index 6e4f3e5..3da6d77 100644 --- a/src/BaseSchema.js +++ b/src/BaseSchema.js @@ -7,6 +7,7 @@ const { patchIdsWithParentId, REQUIRED, setAttribute, + setRaw, setComposeType, FLUENT_SCHEMA, } = require('./utils') @@ -361,6 +362,22 @@ const BaseSchema = ( }) }, + /** + * Because the differences between JSON Schemas and Open API (Swagger) + * it can be handy to arbitrary modify the schema injecting a fragment + * + * * Examples: + * - S.number().raw({ nullable:true }) + * - S.string().format('date').raw({ formatMaximum: '2020-01-01' }) + * + * @param {string} fragment an arbitrary JSON Schema to inject + * {@link reference|https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.3.3} + * @returns {BaseSchema} + */ + raw: fragment => { + return setRaw({ schema, ...options }, fragment) + }, + /** * @private It returns the internal schema data structure * @returns {object} diff --git a/src/BaseSchema.test.js b/src/BaseSchema.test.js index cada5b4..e14d3bd 100644 --- a/src/BaseSchema.test.js +++ b/src/BaseSchema.test.js @@ -570,4 +570,18 @@ describe('BaseSchema', () => { }) }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = BaseSchema() + .title('foo') + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + title: 'foo', + customKeyword: true, + }) + }) + }) }) diff --git a/src/BooleanSchema.test.js b/src/BooleanSchema.test.js index 0f8a740..6e5fdbf 100644 --- a/src/BooleanSchema.test.js +++ b/src/BooleanSchema.test.js @@ -31,4 +31,17 @@ describe('BooleanSchema', () => { .valueOf().properties.prop.type ).toEqual('boolean') }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = BooleanSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'boolean', + customKeyword: true, + }) + }) + }) }) diff --git a/src/FluentSchema.d.ts b/src/FluentSchema.d.ts index bbe1ff2..35ca4f8 100644 --- a/src/FluentSchema.d.ts +++ b/src/FluentSchema.d.ts @@ -21,6 +21,7 @@ export interface BaseSchema { readOnly: (isReadOnly?: boolean) => T writeOnly: (isWriteOnly?: boolean) => T isFluentSchema: boolean + raw: (fragment: any) => JSONSchema } export type TYPE = @@ -102,7 +103,7 @@ export interface ArraySchema extends BaseSchema { contains: (value: JSONSchema | boolean) => ArraySchema uniqueItems: (boolean: boolean) => ArraySchema minItems: (min: number) => ArraySchema - maxItems: (min: number) => ArraySchema + maxItems: (max: number) => ArraySchema } export interface ObjectSchema extends BaseSchema { @@ -148,6 +149,7 @@ export interface S extends BaseSchema { null: () => NullSchema //FIXME LS we should return only a MixedSchema mixed: (types: TYPE[]) => MixedSchema & any + raw: (fragment: any) => JSONSchema } declare var s: S diff --git a/src/FluentSchema.integration.test.js b/src/FluentSchema.integration.test.js index 5d9cd32..001b7ab 100644 --- a/src/FluentSchema.integration.test.js +++ b/src/FluentSchema.integration.test.js @@ -482,4 +482,101 @@ describe('S', () => { ).toEqual(step.schema) }) }) + + describe('raw', () => { + describe('swaggger', () => { + describe('nullable', () => { + it('allows nullable', () => { + const ajv = new Ajv({ nullable: true }) + const schema = S.object() + .prop('foo', S.raw({ nullable: true, type: 'string' })) + .valueOf() + const validate = ajv.compile(schema) + var valid = validate({ + test: null, + }) + expect(validate.errors).toEqual(null) + expect(valid).toBeTruthy() + }) + }) + }) + + describe('ajv', () => { + describe('formatMaximum', () => { + it('checks custom keyword formatMaximum', () => { + const ajv = new Ajv() + require('ajv-keywords/keywords/formatMaximum')(ajv) + /* const schema = S.string() + .raw({ nullable: false }) + .valueOf()*/ + // { type: 'number', nullable: true } + const schema = S.object() + .prop( + 'birthday', + S.raw({ + format: 'date', + formatMaximum: '2020-01-01', + type: 'string', + }) + ) + .valueOf() + + const validate = ajv.compile(schema) + var valid = validate({ + birthday: '2030-01-01', + }) + expect(validate.errors).toEqual([ + { + dataPath: '.birthday', + keyword: 'formatMaximum', + message: 'should be <= "2020-01-01"', + params: { + comparison: '<=', + exclusive: false, + limit: '2020-01-01', + }, + schemaPath: '#/properties/birthday/formatMaximum', + }, + ]) + expect(valid).toBeFalsy() + }) + it('checks custom keyword larger with $data', () => { + const ajv = new Ajv({ $data: true }) + require('ajv-keywords/keywords/formatMaximum')(ajv) + /* const schema = S.string() + .raw({ nullable: false }) + .valueOf()*/ + // { type: 'number', nullable: true } + const schema = S.object() + .prop('smaller', S.number().raw({ maximum: { $data: '1/larger' } })) + .prop('larger', S.number()) + .valueOf() + + const validate = ajv.compile(schema) + var valid = validate({ + smaller: 10, + larger: 7, + }) + expect(validate.errors).toEqual([ + { + dataPath: '.smaller', + keyword: 'maximum', + message: 'should be <= 7', + params: { + comparison: '<=', + exclusive: false, + limit: 7, + }, + schemaPath: '#/properties/smaller/maximum', + }, + ]) + expect(valid).toBeFalsy() + }) + }) + }) + + describe('complex', () => { + it('works', () => {}) + }) + }) }) diff --git a/src/FluentSchema.js b/src/FluentSchema.js index 40b56d0..8be3380 100644 --- a/src/FluentSchema.js +++ b/src/FluentSchema.js @@ -1,5 +1,4 @@ 'use strict' -const merge = require('deepmerge') const { FORMATS, TYPES } = require('./utils') @@ -12,6 +11,7 @@ const { IntegerSchema } = require('./IntegerSchema') const { ObjectSchema } = require('./ObjectSchema') const { ArraySchema } = require('./ArraySchema') const { MixedSchema } = require('./MixedSchema') +const { RawSchema } = require('./RawSchema') const initialState = { $schema: 'http://json-schema.org/draft-07/schema#', @@ -163,9 +163,27 @@ const S = ( factory: MixedSchema, }) }, + + /** + * Because the differences between JSON Schemas and Open API (Swagger) + * it can be handy to arbitrary modify the schema injecting a fragment + * + * * Examples: + * - S.raw({ nullable:true, format: 'date', formatMaximum: '2020-01-01' }) + * - S.string().format('date').raw({ formatMaximum: '2020-01-01' }) + * + * @param {string} fragment an arbitrary JSON Schema to inject + * {@link reference|https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.3.3} + * @returns {BaseSchema} + */ + + raw: fragment => { + return RawSchema(fragment) + }, }) module.exports = { + ...BaseSchema(), FORMATS, TYPES, withOptions: S, @@ -177,5 +195,5 @@ module.exports = { integer: () => S().integer(), number: () => S().number(), null: () => S().null(), - ...BaseSchema(), + raw: fragment => S().raw(fragment), } diff --git a/src/FluentSchema.test.js b/src/FluentSchema.test.js index 58d6732..fefe3ae 100644 --- a/src/FluentSchema.test.js +++ b/src/FluentSchema.test.js @@ -329,4 +329,244 @@ describe('S', () => { }, }) }) + + describe('raw', () => { + describe('base', () => { + it('parses type', () => { + const input = S.enum(['foo']).valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.enum(['foo']).valueOf() + const schema = S.raw(input) + const attribute = 'title' + const modified = schema.title(attribute) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + title: attribute, + }) + }) + }) + + describe('string', () => { + it('parses type', () => { + const input = S.string().valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.string().valueOf() + const schema = S.raw(input) + const modified = schema.minLength(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + minLength: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.string() + .minLength(5) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('number', () => { + it('parses type', () => { + const input = S.number().valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.number().valueOf() + const schema = S.raw(input) + const modified = schema.maximum(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + maximum: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.number() + .maximum(5) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('integer', () => { + it('parses type', () => { + const input = S.integer().valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.integer().valueOf() + const schema = S.raw(input) + const modified = schema.maximum(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + maximum: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.integer() + .maximum(5) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('boolean', () => { + it('parses type', () => { + const input = S.boolean().valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('object', () => { + it('parses type', () => { + const input = S.object().valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses properties', () => { + const input = S.object() + .prop('foo') + .prop('bar', S.string()) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses nested properties', () => { + const input = S.object() + .prop('foo', S.object().prop('bar', S.string().minLength(3))) + .valueOf() + const schema = S.raw(input) + const modified = schema.prop('boom') + expect(modified.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + properties: { + ...input.properties, + boom: {}, + }, + }) + }) + + it('parses definitions', () => { + const input = S.object() + .definition('foo', S.string()) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('array', () => { + it('parses type', () => { + const input = S.array() + .items(S.string()) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses properties', () => { + const input = S.array() + .items(S.string()) + .valueOf() + + const schema = S.raw(input).maxItems(1) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + maxItems: 1, + }) + }) + + it('parses nested properties', () => { + const input = S.array() + .items( + S.object().prop( + 'foo', + S.object().prop('bar', S.string().minLength(3)) + ) + ) + .valueOf() + const schema = S.raw(input) + const modified = schema.maxItems(1) + expect(modified.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + maxItems: 1, + }) + }) + + it('parses definitions', () => { + const input = S.object() + .definition('foo', S.string()) + .valueOf() + const schema = S.raw(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + }) }) diff --git a/src/IntegerSchema.test.js b/src/IntegerSchema.test.js index 374735e..6be1a1f 100644 --- a/src/IntegerSchema.test.js +++ b/src/IntegerSchema.test.js @@ -177,6 +177,19 @@ describe('IntegerSchema', () => { }) }) + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = IntegerSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'integer', + customKeyword: true, + }) + }) + }) + it('works', () => { const schema = S.object() .id('http://foo.com/user') diff --git a/src/MixedSchema.test.js b/src/MixedSchema.test.js index 924e17b..484a6aa 100644 --- a/src/MixedSchema.test.js +++ b/src/MixedSchema.test.js @@ -84,4 +84,20 @@ describe('MixedSchema', () => { type: 'object', }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const types = [S.TYPES.STRING, S.TYPES.NUMBER] + + const schema = S.mixed(types) + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: ['string', 'number'], + customKeyword: true, + }) + }) + }) }) diff --git a/src/NullSchema.js b/src/NullSchema.js index 47f71b1..fc64bb4 100644 --- a/src/NullSchema.js +++ b/src/NullSchema.js @@ -20,9 +20,10 @@ const NullSchema = ({ schema = initialState, ...options } = {}) => { factory: NullSchema, ...options, } - const { valueOf } = BaseSchema({ ...options, schema }) + const { valueOf, raw } = BaseSchema({ ...options, schema }) return { valueOf, + raw, [FLUENT_SCHEMA]: true, isFluentSchema: true, diff --git a/src/NullSchema.test.js b/src/NullSchema.test.js index 04027b8..682451c 100644 --- a/src/NullSchema.test.js +++ b/src/NullSchema.test.js @@ -31,4 +31,17 @@ describe('NullSchema', () => { .valueOf().properties.prop.type ).toEqual('null') }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = NullSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'null', + customKeyword: true, + }) + }) + }) }) diff --git a/src/NumberSchema.test.js b/src/NumberSchema.test.js index 69c4420..677f0d0 100644 --- a/src/NumberSchema.test.js +++ b/src/NumberSchema.test.js @@ -150,6 +150,19 @@ describe('NumberSchema', () => { ) }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = NumberSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'number', + customKeyword: true, + }) + }) + }) }) it('works', () => { diff --git a/src/ObjectSchema.test.js b/src/ObjectSchema.test.js index 2ed6efe..e51dde6 100644 --- a/src/ObjectSchema.test.js +++ b/src/ObjectSchema.test.js @@ -696,4 +696,17 @@ describe('ObjectSchema', () => { }).toThrow("Schema isn't FluentSchema type") }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = ObjectSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'object', + customKeyword: true, + }) + }) + }) }) diff --git a/src/RawSchema.js b/src/RawSchema.js new file mode 100644 index 0000000..fb0f17a --- /dev/null +++ b/src/RawSchema.js @@ -0,0 +1,92 @@ +'use strict' +const merge = require('deepmerge') +const { BaseSchema } = require('./BaseSchema') +const { NullSchema } = require('./NullSchema') +const { BooleanSchema } = require('./BooleanSchema') +const { StringSchema } = require('./StringSchema') +const { NumberSchema } = require('./NumberSchema') +const { IntegerSchema } = require('./IntegerSchema') +const { ObjectSchema } = require('./ObjectSchema') +const { ArraySchema } = require('./ArraySchema') +const { MixedSchema } = require('./MixedSchema') +const { toArray } = require('./utils') + +/** + * Represents a raw JSON Schema that will be parsed + * @param {Object} schema + * @returns {FluentSchema} + */ + +const RawSchema = (schema = {}) => { + if (typeof schema !== 'object') { + throw new Error('A fragment must be a JSON object') + } + const { type, definitions, properties, required, ...props } = schema + switch (schema.type) { + case 'string': { + const schema = { + type, + ...props, + } + return StringSchema({ schema, factory: StringSchema }) + } + + case 'integer': { + const schema = { + type, + ...props, + } + return IntegerSchema({ schema, factory: NumberSchema }) + } + case 'number': { + const schema = { + type, + ...props, + } + return NumberSchema({ schema, factory: NumberSchema }) + } + + case 'boolean': { + const schema = { + type, + ...props, + } + return BooleanSchema({ schema, factory: BooleanSchema }) + } + + case 'object': { + const schema = { + type, + definitions: toArray(definitions) || [], + properties: toArray(properties) || [], + required: required || [], + ...props, + } + return ObjectSchema({ schema, factory: ObjectSchema }) + } + + case 'array': { + const schema = { + type, + ...props, + } + return ArraySchema({ schema, factory: ArraySchema }) + } + + default: { + const schema = { + ...props, + } + + return BaseSchema({ + schema, + factory: BaseSchema, + }) + } + } +} + +module.exports = { + RawSchema, + default: RawSchema, +} diff --git a/src/RawSchema.test.js b/src/RawSchema.test.js new file mode 100644 index 0000000..11c1196 --- /dev/null +++ b/src/RawSchema.test.js @@ -0,0 +1,281 @@ +const { RawSchema } = require('./RawSchema') +const S = require('./FluentSchema') + +describe('RawSchema', () => { + it('defined', () => { + expect(RawSchema).toBeDefined() + }) + + it('Expose symbol', () => { + expect(RawSchema()[Symbol.for('fluent-schema-object')]).toBeDefined() + }) + + describe('base', () => { + it('parses type', () => { + const input = S.enum(['foo']).valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.enum(['foo']).valueOf() + const schema = RawSchema(input) + const attribute = 'title' + const modified = schema.title(attribute) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + title: attribute, + }) + }) + + it("throws an exception if the input isn't an object", () => { + expect(() => RawSchema('boom!')).toThrow( + 'A fragment must be a JSON object' + ) + }) + }) + + describe('string', () => { + it('parses type', () => { + const input = S.string().valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.string().valueOf() + const schema = RawSchema(input) + const modified = schema.minLength(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + minLength: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.string() + .minLength(5) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('number', () => { + it('parses type', () => { + const input = S.number().valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.number().valueOf() + const schema = RawSchema(input) + const modified = schema.maximum(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + maximum: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.number() + .maximum(5) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('integer', () => { + it('parses type', () => { + const input = S.integer().valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('adds an attribute', () => { + const input = S.integer().valueOf() + const schema = RawSchema(input) + const modified = schema.maximum(3) + expect(schema.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + maximum: 3, + ...input, + }) + }) + + it('parses a prop', () => { + const input = S.integer() + .maximum(5) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('boolean', () => { + it('parses type', () => { + const input = S.boolean().valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('object', () => { + it('parses type', () => { + const input = S.object().valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses properties', () => { + const input = S.object() + .prop('foo') + .prop('bar', S.string()) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses nested properties', () => { + const input = S.object() + .prop('foo', S.object().prop('bar', S.string().minLength(3))) + .valueOf() + const schema = RawSchema(input) + const modified = schema.prop('boom') + expect(modified.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + properties: { + ...input.properties, + boom: {}, + }, + }) + }) + + it('parses definitions', () => { + const input = S.object() + .definition('foo', S.string()) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + describe('array', () => { + it('parses type', () => { + const input = S.array() + .items(S.string()) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + + it('parses properties', () => { + const input = S.array() + .items(S.string()) + .valueOf() + + const schema = RawSchema(input).maxItems(1) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + maxItems: 1, + }) + }) + + it('parses nested properties', () => { + const input = S.array() + .items( + S.object().prop( + 'foo', + S.object().prop('bar', S.string().minLength(3)) + ) + ) + .valueOf() + const schema = RawSchema(input) + const modified = schema.maxItems(1) + expect(modified.isFluentSchema).toBeTruthy() + expect(modified.valueOf()).toEqual({ + ...input, + maxItems: 1, + }) + }) + + it('parses definitions', () => { + const input = S.object() + .definition('foo', S.string()) + .valueOf() + const schema = RawSchema(input) + expect(schema.isFluentSchema).toBeTruthy() + expect(schema.valueOf()).toEqual({ + ...input, + }) + }) + }) + + /* describe('constructor', () => { + it('without params', () => { + expect(RawSchema().valueOf()).toEqual({ + // $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + }) + + }) + + it('from S', () => { + expect(S.object().valueOf()).toEqual({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + }) + + it('valueOf', () => { + expect( + ObjectSchema() + .prop('foo', S.string()) + .valueOf() + ).toEqual({ properties: { foo: { type: 'string' } }, type: 'object' }) + })*/ +}) diff --git a/src/StringSchema.js b/src/StringSchema.js index bcd2d19..a28a278 100644 --- a/src/StringSchema.js +++ b/src/StringSchema.js @@ -4,7 +4,7 @@ const { last, FORMATS, setAttribute } = require('./utils') const initialState = { type: 'string', - properties: [], + // properties: [], //FIXME it shouldn't be set for a string because it has only attributes required: [], } diff --git a/src/StringSchema.test.js b/src/StringSchema.test.js index e7f609f..95fdeb3 100644 --- a/src/StringSchema.test.js +++ b/src/StringSchema.test.js @@ -155,6 +155,31 @@ describe('StringSchema', () => { ) }) }) + + describe('raw', () => { + it('allows to add a custom attribute', () => { + const schema = StringSchema() + .raw({ customKeyword: true }) + .valueOf() + + expect(schema).toEqual({ + type: 'string', + customKeyword: true, + }) + }) + it('allows to mix custom attibutes with regular one', () => { + const schema = StringSchema() + .format('date') + .raw({ formatMaximum: '2020-01-01' }) + .valueOf() + + expect(schema).toEqual({ + type: 'string', + formatMaximum: '2020-01-01', + format: 'date', + }) + }) + }) }) it('works', () => { diff --git a/src/example.js b/src/example.js index 330b870..d69d4c2 100644 --- a/src/example.js +++ b/src/example.js @@ -29,12 +29,16 @@ const schema = S.object() .enum(Object.values(ROLES)) .default(ROLES.USER) ) + .prop( + 'birthday', + S.raw({ type: 'string', format: 'date', formatMaximum: '2020-01-01' }) // formatMaximum is an AJV custom keywords + ) .definition( 'address', S.object() .id('#address') - .prop('line1', S.string()) - .prop('line2', S.anyOf([S.string(), S.null()])) + .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable + .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable .prop('country', S.string()) .prop('city', S.string()) .prop('zipcode', S.string()) diff --git a/src/types/index.ts b/src/types/index.ts index 3939f9e..73d1b18 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,8 +11,8 @@ const schema = S.object() 'address', S.object() .id('#address') - .prop('line1') - .prop('line2', S.anyOf([S.string(), S.null()])) + .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable + .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable .prop('country') .allOf([S.string()]) .prop('city') diff --git a/src/types/tsconfig.json b/src/types/tsconfig.json index 160246b..59f3c7c 100644 --- a/src/types/tsconfig.json +++ b/src/types/tsconfig.json @@ -3,7 +3,7 @@ "target": "es6", "module": "commonjs", "esModuleInterop": true, - "noEmit": false, + "noEmit": true, "strict": true }, "files": ["./index.ts"] diff --git a/src/utils.js b/src/utils.js index 194c790..2fd7808 100644 --- a/src/utils.js +++ b/src/utils.js @@ -6,6 +6,7 @@ const hasCombiningKeywords = attributes => attributes.allOf || attributes.anyOf || attributes.oneOf || attributes.not const last = array => { + if (!array) return const [prop] = [...array].reverse() return prop } @@ -28,6 +29,9 @@ const flat = array => } }, {}) +const toArray = obj => + obj && Object.entries(obj).map(([key, value]) => ({ name: key, ...value })) + const REQUIRED = Symbol('required') const FLUENT_SCHEMA = Symbol.for('fluent-schema-object') @@ -149,6 +153,18 @@ const setAttribute = ({ schema, ...options }, attribute) => { } return options.factory({ schema: { ...schema, [key]: value }, ...options }) } + +const setRaw = ({ schema, ...options }, raw) => { + const currentProp = last(schema.properties) + if (currentProp) { + const { name, ...props } = currentProp + return options.factory({ schema, ...options }).prop(name, { + ...raw, + ...props, + }) + } + return options.factory({ schema: { ...schema, ...raw }, ...options }) +} // TODO LS maybe we can just use setAttribute and remove this one const setComposeType = ({ prop, schemas, schema, options }) => { if (!(Array.isArray(schemas) && schemas.every(v => isFluentSchema(v)))) { @@ -170,10 +186,12 @@ module.exports = { hasCombiningKeywords, last, flat, + toArray, omit, REQUIRED, patchIdsWithParentId, appendRequired, + setRaw, setAttribute, setComposeType, FORMATS, diff --git a/src/utils.test.js b/src/utils.test.js new file mode 100644 index 0000000..d33f0b0 --- /dev/null +++ b/src/utils.test.js @@ -0,0 +1,33 @@ +const { setRaw } = require('./utils') +const { StringSchema } = require('./StringSchema') +const { ObjectSchema } = require('./ObjectSchema') + +describe('setRaw', () => { + it('add an attribute to a prop', () => { + const factory = ObjectSchema + const schema = setRaw( + { schema: { properties: [{ name: 'foo', type: 'string' }] }, factory }, + { nullable: true } + ) + expect(schema.valueOf()).toEqual({ + properties: { + foo: { + nullable: true, + type: 'string', + }, + }, + }) + }) + + it('add an attribute to a prop', () => { + const factory = StringSchema + const schema = setRaw( + { schema: { type: 'string', properties: [] }, factory }, + { nullable: true } + ) + expect(schema.valueOf()).toEqual({ + nullable: true, + type: 'string', + }) + }) +})