Skip to content

Nested required object properties are seeded as top-level keys in the request body #1200

Description

@jocelynnarbor

Summary

getJSONSchemaDefaults seeds an empty object for every property that is both listed in its parent's required array and typed object. It writes those to the root of the defaults object rather than to the property's own position in the tree. Those defaults are then merged with the caller's body, so a required object nested anywhere in a request schema is also sent as a spurious top-level key.

For an API that declares additionalProperties: false, this makes every affected request fail validation server-side.

Reproduction

const APICore = require('@readme/api-core').default;

const definition = {
  openapi: '3.0.0',
  info: { title: 'Example', version: '1.0' },
  servers: [{ url: 'https://api.example.com' }],
  paths: {
    '/orders': {
      post: {
        requestBody: {
          content: {
            'application/json': {
              schema: {
                type: 'object',
                additionalProperties: false,
                required: ['reference', 'customer'],
                properties: {
                  reference: { type: 'string' },
                  customer: {
                    type: 'object',
                    required: ['address'],
                    properties: {
                      address: {
                        type: 'object',
                        required: ['city'],
                        properties: { city: { type: 'string' } },
                      },
                    },
                  },
                },
              },
            },
          },
        },
        responses: { 200: { description: 'ok' } },
      },
    },
  },
};

(async () => {
  let sent;
  global.fetch = async (input, init) => {
    const request = input instanceof Request ? input : undefined;
    sent = request ? await request.text() : init?.body;
    return new Response('{}', {
      status: 200,
      headers: { 'content-type': 'application/json' },
    });
  };

  const core = new APICore(definition);
  core.setServer('https://api.example.com');

  await core.fetch('/orders', 'post', {
    reference: 'ORD-1',
    customer: { address: { city: 'Berlin' } },
  });

  console.log(sent);
})();

Expected

{"reference":"ORD-1","customer":{"address":{"city":"Berlin"}}}

Actual

{"customer":{"address":{"city":"Berlin"}},"address":{},"reference":"ORD-1"}

address is required on customer, not on the root, but is sent at the root as {}.

Cause

In getJSONSchemaDefaults, the object-seeding branch writes to defaults directly, ignoring the destination the same block computes from parentPointer for the schema.default case just below it:

if (parentSchema?.required?.includes(String(indexProperty))) {
  if (schema.type === 'object' && indexProperty) {
    defaults[indexProperty] = {};   // <-- root, regardless of depth
  }

  let destination = defaults;
  if (parentPointer) {
    // ...walks parentPointer down to the correct nested position
  }

  if (schema.default !== undefined) {
    destination[indexProperty] = schema.default;   // <-- uses the nested position
  }
}

The nesting-aware path already exists; it just isn't applied to the object branch.

Versions

Reproduced on @readme/api-core 7.0.0. The function is byte-identical in 7.0.2. The same code is present in api 6.x.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions