Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: JSDoc null values being interpreted as strings, and added test README #1377

Merged
merged 3 commits into from Sep 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/AnnotationsReader/BasicAnnotationsReader.ts
Expand Up @@ -93,10 +93,13 @@ export class BasicAnnotationsReader implements AnnotationsReader {

if (isTextTag) {
return text;
} else if (BasicAnnotationsReader.jsonTags.has(jsDocTag.name)) {
return this.parseJson(text) ?? text;
}
let parsed = this.parseJson(text);
parsed = parsed === undefined ? text : parsed;
if (BasicAnnotationsReader.jsonTags.has(jsDocTag.name)) {
return parsed;
} else if (this.extraTags?.has(jsDocTag.name)) {
return this.parseJson(text) ?? text;
return parsed;
} else {
// Unknown jsDoc tag.
return undefined;
Expand Down
13 changes: 13 additions & 0 deletions test/README.md
@@ -0,0 +1,13 @@
# Testing

## Schema Generation

To add/update a test case for generating a valid schema from a Typescript file:

- Look in `test/valid-data` for a sample related to your change. If you don't find one, create your own following the naming convention. For example, when adding the new sample `annotation-default`:
- Create folder `test/valid-data/annotation-default`
- Add `main.ts` to that folder with the type sample
- Update the corresponding `main.ts` file with your changes.
- Run `yarn test:update` to compile the JSON schema
- Add a test to `test/valid-data-annotations.test.ts`, matching a similar pattern to the existing tests.
- Run tests via `yarn jest test/valid-data-annotations.test.ts` (this only runs the subset of tests related to schema validation)
57 changes: 52 additions & 5 deletions test/utils.ts
@@ -1,8 +1,8 @@
import Ajv from "ajv";
import Ajv, { Options as AjvOptions } from "ajv";
import addFormats from "ajv-formats";
import { readFileSync, writeFileSync } from "fs";
import stringify from "safe-stable-stringify";
import { resolve } from "path";
import stringify from "safe-stable-stringify";
import ts from "typescript";
import { createFormatter } from "../factory/formatter";
import { createParser } from "../factory/parser";
Expand All @@ -25,7 +25,29 @@ export function assertValidSchema(
type?: string,
jsDoc: Config["jsDoc"] = "none",
extraTags?: Config["extraTags"],
schemaId?: Config["schemaId"]
schemaId?: Config["schemaId"],
options?: {
/**
* Array of sample data
* that should
* successfully validate.
*/
validSamples?: any[];
/**
* Array of sample data
* that should
* fail to validate.
*/
invalidSamples?: any[];
/**
* Options to pass to Ajv
* when creating the Ajv
* instance.
*
* @default {strict:false}
*/
ajvOptions?: AjvOptions;
}
) {
return (): void => {
const config: Config = {
Expand Down Expand Up @@ -56,12 +78,37 @@ export function assertValidSchema(

let localValidator = validator;
if (extraTags) {
localValidator = new Ajv({ strict: false });
localValidator = new Ajv(options?.ajvOptions || { strict: false });
addFormats(localValidator);
}

localValidator.validateSchema(actual);
expect(localValidator.errors).toBeNull();
localValidator.compile(actual); // Will find MissingRef errors

// Compile in all cases to detect MissingRef errors
const validate = localValidator.compile(actual);

// Use the compiled validator if there
// are any samples.
if (options?.invalidSamples) {
for (const sample of options.invalidSamples) {
const isValid = validate(sample);
if (isValid) {
console.log("Unexpectedly Valid:", sample);
}
expect(isValid).toBe(false);
}
}
if (options?.validSamples) {
for (const sample of options.validSamples) {
const isValid = validate(sample);
if (!isValid) {
console.log("Unexpectedly Invalid:", sample);

console.log("AJV Errors:", validate.errors);
}
expect(isValid).toBe(true);
}
}
};
}
29 changes: 29 additions & 0 deletions test/valid-data-annotations.test.ts
@@ -1,4 +1,5 @@
import { assertValidSchema } from "./utils";
import * as annotationDefaultSamples from "./valid-data/annotation-default/samples";

describe("valid-data-annotations", () => {
it(
Expand Down Expand Up @@ -33,6 +34,34 @@ describe("valid-data-annotations", () => {

it("annotation-comment", assertValidSchema("annotation-comment", "MyObject", "extended"));

it("annotation-default", function () {
// Without actually using the defaults.
assertValidSchema("annotation-default", "MyObject", "extended", [], undefined, {
validSamples: annotationDefaultSamples.validSamples,
invalidSamples: annotationDefaultSamples.invalidSamplesUnlessDefaults,
})();

// Having AJV use the defaults.

// Since AJV will mutate, make
// shallow copies.
const validWithDefaults = annotationDefaultSamples.invalidSamplesUnlessDefaults.map((sample) => ({
...sample,
}));

assertValidSchema("annotation-default", "MyObject", "extended", [], undefined, {
validSamples: validWithDefaults,
ajvOptions: { useDefaults: true },
})();

// The previously-invalid samples
// should now match the expected
// structure when all defaults are applied.
validWithDefaults.forEach((sample) => {
expect(sample).toEqual(annotationDefaultSamples.expectedAfterDefaults);
});
});

it("annotation-example", assertValidSchema("annotation-example", "MyObject", "extended"));

it("annotation-id", assertValidSchema("annotation-id", "MyObject", "extended", [], "Test"));
Expand Down
31 changes: 31 additions & 0 deletions test/valid-data/annotation-default/main.ts
@@ -0,0 +1,31 @@
export interface MyObject {
/**
* @default {extra: {field:"value"}}
*/
nestedField: MyNestedObject;
/**
* @default 10
*/
numberField: number;
/**
* @default "hello"
*/
stringField: string;
/**
* @default true
*/
booleanField?: boolean;
/**
* @default null
*/
nullField: null;
/**
* @default [{ numberField2: 10, stringField2: "yes", anyField: null }]
*/
arrayField: Array<{ numberField2: number; stringField2: string; anyField?: any }>;
}

/**
* @default {}
*/
export type MyNestedObject = Record<string, any>;
48 changes: 48 additions & 0 deletions test/valid-data/annotation-default/samples.ts
@@ -0,0 +1,48 @@
import type { MyObject } from "./main.js";

export const validSamples: MyObject[] = [
{
nullField: null,
numberField: 100,
stringField: "goodbye",
arrayField: [],
booleanField: false,
nestedField: {},
},
];

/**
* Samples that should be *invalid* if
* AJV is not using `useDefaults: true`,
* and otherwise valid. The resulting
* mutated object should be the same
* in all cases.
*/
export const invalidSamplesUnlessDefaults: any[] = [
{
nullField: null,
numberField: 10,
stringField: "hello",
},
{},
];

/**
* The resulting data structure after
* `useDefaults` is used with the
* {@link invalidSamples} entries.
*
* We aren't testing AJV's behavior here.
* We're assuming that if AJV populates
* defaults, and those defaults are of
* the expected values, then this project
* must be working correctly.
*/
export const expectedAfterDefaults: MyObject = {
nullField: null,
numberField: 10,
stringField: "hello",
arrayField: [{ numberField2: 10, stringField2: "yes", anyField: null }],
booleanField: true,
nestedField: { extra: { field: "value" } },
};
74 changes: 74 additions & 0 deletions test/valid-data/annotation-default/schema.json
@@ -0,0 +1,74 @@
{
"$ref": "#/definitions/MyObject",
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"MyNestedObject": {
"default": {},
"type": "object"
},
"MyObject": {
"additionalProperties": false,
"properties": {
"arrayField": {
"default": [
{
"anyField": null,
"numberField2": 10,
"stringField2": "yes"
}
],
"items": {
"additionalProperties": false,
"properties": {
"anyField": {},
"numberField2": {
"type": "number"
},
"stringField2": {
"type": "string"
}
},
"required": [
"numberField2",
"stringField2"
],
"type": "object"
},
"type": "array"
},
"booleanField": {
"default": true,
"type": "boolean"
},
"nestedField": {
"$ref": "#/definitions/MyNestedObject",
"default": {
"extra": {
"field": "value"
}
}
},
"nullField": {
"default": null,
"type": "null"
},
"numberField": {
"default": 10,
"type": "number"
},
"stringField": {
"default": "hello",
"type": "string"
}
},
"required": [
"nestedField",
"numberField",
"stringField",
"nullField",
"arrayField"
],
"type": "object"
}
}
}