spectral icon indicating copy to clipboard operation
spectral copied to clipboard

Enable AdditionalProperties:false override

Open savage-alex opened this issue 4 years ago • 1 comments

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.

savage-alex avatar Dec 21 '21 14:12 savage-alex

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.

derbylock avatar Dec 07 '22 16:12 derbylock