stoplightio / spectral

A flexible JSON/YAML linter for creating automated style guides, with baked in support for OpenAPI (v3.1, v3.0, and v2.0), Arazzo v1.0, as well as AsyncAPI v2.x.
https://stoplight.io/spectral
Apache License 2.0
2.53k stars 241 forks source link

Enable AdditionalProperties:false override #2008

Open savage-alex opened 2 years ago

savage-alex commented 2 years ago

User story. As an API designer I want to ensure my examples are correctly spelled and typed for properties that are in my API definition So that any consumers of mocked endpoints do not get incorrect properties

Is your feature request related to a problem? Spectral can find examples that are bad when additionalProperties is set to false but its not something we want to do when we release API definitions as its stops evolution

Describe the solution you'd like A mode for spectral to lint the examples against the definition and to ensure no additionalProperties are present (expect it to be a additional mode)

Additional context Add any other context or screenshots about the feature request here.

derbylock commented 1 year ago

I've solved the same problem by creating custom function base on oasExample:

rules.yaml:

  oas3-valid-schema-example-strict:
    severity: error
    message: "{{error}}"
    recommended: true
    formats: ["oas3"]
    given:
      - "$.components.schemas..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
      - "$..content..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
      - "$..headers..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
      - "$..parameters..[?(@property !== 'properties' && @ && (@ && @.example !== void 0 || @.default !== void 0) && (@.enum || @.type || @.format || @.$ref || @.properties || @.items))]"
    then:
      function: oasExampleStrict
      functionOptions:
        schemaField: "$"
        oasVersion: 3
        type: "schema"
  oas3-valid-media-example-strict:
    severity: error
    message: "{{error}}"
    recommended: true
    formats: ["oas3"]
    given:
      - "$..content..[?(@ && @.schema && (@.example !== void 0 || @.examples))]"
      - "$..headers..[?(@ && @.schema && (@.example !== void 0 || @.examples))]"
      - "$..parameters..[?(@ && @.schema && (@.example !== void 0 || @.examples))]"
    then:
      function: oasExampleStrict
      functionOptions:
        schemaField: "schema"
        oasVersion: 3
        type: "media"

oasExampleStrict.ts

import { isObject } from './isObject';
import type { Dictionary, JsonPath, Optional } from '@stoplight/types';
import oasSchema, { Options as SchemaOptions } from './oasSchema';
import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core';
import { oas2 } from '@stoplight/spectral-formats';
import { apierr } from './sm_common';

export type Options = {
  oasVersion: 2 | 3;
  schemaField: string;
  type: 'media' | 'schema';
};

type MediaValidationItem = {
  field: string;
  multiple: boolean;
  keyed: boolean;
};

const MEDIA_VALIDATION_ITEMS: Dictionary<MediaValidationItem[], 2 | 3> = {
  2: [
    {
      field: 'examples',
      multiple: true,
      keyed: false,
    },
  ],
  3: [
    {
      field: 'example',
      multiple: false,
      keyed: false,
    },
    {
      field: 'examples',
      multiple: true,
      keyed: true,
    },
  ],
};

const SCHEMA_VALIDATION_ITEMS: Dictionary<string[], 2 | 3> = {
  2: ['example', 'x-example', 'default'],
  3: ['example', 'default'],
};

type ValidationItem = {
  value: unknown;
  path: JsonPath;
};

function* getMediaValidationItems(
  items: MediaValidationItem[],
  targetVal: Dictionary<unknown>,
  givenPath: JsonPath,
  oasVersion: 2 | 3,
): Iterable<ValidationItem> {
  for (const { field, keyed, multiple } of items) {
    if (!(field in targetVal)) {
      continue;
    }

    const value = targetVal[field];

    if (multiple) {
      if (!isObject(value)) continue;

      for (const exampleKey of Object.keys(value)) {
        const exampleValue = value[exampleKey];
        if (oasVersion === 3 && keyed && (!isObject(exampleValue) || 'externalValue' in exampleValue)) {
          // should be covered by oas3-examples-value-or-externalValue
          continue;
        }

        const targetPath = [...givenPath, field, exampleKey];

        if (keyed) {
          targetPath.push('value');
        }

        yield {
          value: keyed && isObject(exampleValue) ? exampleValue.value : exampleValue,
          path: targetPath,
        };
      }

      return;
    } else {
      return yield {
        value,
        path: [...givenPath, field],
      };
    }
  }
}

function* getSchemaValidationItems(
  fields: string[],
  targetVal: Record<string, unknown>,
  givenPath: JsonPath,
): Iterable<ValidationItem> {
  for (const field of fields) {
    if (!(field in targetVal)) {
      continue;
    }

    yield {
      value: targetVal[field],
      path: [...givenPath, field],
    };
  }
}

export default createRulesetFunction<Record<string, unknown>, Options>(
  {
    input: {
      type: 'object',
    },
    options: {
      type: 'object',
      properties: {
        oasVersion: {
          enum: [2, 3],
        },
        schemaField: {
          type: 'string',
        },
        type: {
          enum: ['media', 'schema'],
        },
      },
      additionalProperties: false,
    },
  },
  function oasExample(targetVal, opts, context) {
    const formats = context.document.formats;
    const schemaOpts: SchemaOptions = {
      schema: opts.schemaField === '$' ? targetVal : (targetVal[opts.schemaField] as SchemaOptions['schema']),
    };

    let results: Optional<IFunctionResult[]> = void 0;

    const validationItems =
      opts.type === 'schema'
        ? getSchemaValidationItems(SCHEMA_VALIDATION_ITEMS[opts.oasVersion], targetVal, context.path)
        : getMediaValidationItems(MEDIA_VALIDATION_ITEMS[opts.oasVersion], targetVal, context.path, opts.oasVersion);

    schemaOpts.schema = Object.assign({}, schemaOpts.schema);
    disableAdditionalProperties(schemaOpts.schema);

    if (formats?.has(oas2) && 'required' in schemaOpts.schema && typeof schemaOpts.schema.required === 'boolean') {
      schemaOpts.schema = { ...schemaOpts.schema };
      delete schemaOpts.schema.required;
    }

    for (const validationItem of validationItems) {
      const result = oasSchema(validationItem.value, schemaOpts, {
        ...context,
        path: validationItem.path,
      });

      if (Array.isArray(result)) {
        if (results === void 0) results = [];
        results.push(...result);
      }
    }
    return results;
  },
);

function disableAdditionalProperties(schema) {
  if (schema.type == "object") {
    schema.additionalProperties = false;
    if (schema.properties && isObject(schema.properties)) {
      schema.properties = Object.assign({}, schema.properties);
      for (const propName in schema.properties) {
        schema.properties[propName] = Object.assign({}, schema.properties[propName]);
        disableAdditionalProperties(schema.properties[propName]);
      }
    }
  }
}

It would be good to have an option in oasExample function and such rules as an optional part of base rules anyway.