Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions packages/oas/src/operation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export class Operation {
*/
contentType: string;

/**
* Selected content type for this operation.
*
* @example 'application/json'
*/
selectedContentType: string;

/**
* An object with groups of all example definitions (body/header/query/path/response/etc.)
*/
Expand Down Expand Up @@ -98,6 +105,7 @@ export class Operation {
this.method = method;

this.contentType = undefined;
this.selectedContentType = undefined;
this.requestBodyExamples = undefined;
this.responseExamples = undefined;
this.callbackExamples = undefined;
Expand Down Expand Up @@ -129,6 +137,10 @@ export class Operation {
}

getContentType(): string {
if (this.selectedContentType) {
return this.selectedContentType;
}

if (this.contentType) {
return this.contentType;
}
Expand Down Expand Up @@ -175,6 +187,22 @@ export class Operation {
return matchesMimeType.xml(this.getContentType());
}

/**
* Sets a selected content type for this operation.
*
* @param selectedContentType Media type to select.
*/
setContentType(selectedContentType: string): void {
this.selectedContentType = selectedContentType;
}

/**
* Returns all available media types for either the request body.
*/
getMediaTypes(): string[] {
return this.getRequestBodyMediaTypes();
}

/**
* Checks if the current operation is a webhook or not.
*
Expand Down Expand Up @@ -490,11 +518,6 @@ export class Operation {
});
}

/**
* Get a single response for this status code, formatted as JSON schema.
*
* @param statusCode Status code to pull a JSON Schema response for.
*/
Comment on lines -493 to -497
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add this back in?

getResponseAsJSONSchema(
statusCode: number | string,
opts: {
Expand All @@ -510,11 +533,18 @@ export class Operation {
* name, just make sure to return your transformed schema.
*/
transformer?: (schema: SchemaObject) => SchemaObject;

/**
* Preferred content type to use when choosing a response schema. If specified, this content
* type will be used in preference to others if multiple response representations are available.
*/
preferContentType?: string;
} = {},
): SchemaObject {
return getResponseAsJSONSchema(this, this.api, statusCode, {
includeDiscriminatorMappingRefs: true,
transformer: (s: SchemaObject) => s,
preferContentType: this.selectedContentType,
...opts,
});
}
Expand Down Expand Up @@ -611,12 +641,22 @@ export class Operation {
return false;
}

if (mediaType) {
if (!(mediaType in requestBody.content)) {
const mediaTypeToUse = mediaType || this.selectedContentType;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a little weird that getResponseAsJSONSchema has the preferredContentType option but this doesn't.


if (mediaTypeToUse) {
if (!(mediaTypeToUse in requestBody.content)) {
return false;
}

return requestBody.content[mediaType];
if (mediaType) {
return requestBody.content[mediaType];
}

return [
mediaTypeToUse,
requestBody.content[mediaTypeToUse],
...(requestBody.description ? [requestBody.description] : []),
];
}

// Since no media type was supplied we need to find either the first JSON-like media type that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function getResponseAsJSONSchema(
statusCode: number | string,
opts?: {
includeDiscriminatorMappingRefs?: boolean;
preferContentType?: string;
/**
* With a transformer you can transform any data within a given schema, like say if you want
* to rewrite a potentially unsafe `title` that might be eventually used as a JS variable
Expand Down Expand Up @@ -140,6 +141,14 @@ export function getResponseAsJSONSchema(
return null;
}

if (opts?.preferContentType && content[opts.preferContentType]) {
return toJSONSchema(cloneObject(content[opts.preferContentType].schema), {
addEnumsToDescriptions: true,
refLogger,
transformer: opts.transformer,
});
}

for (let i = 0; i < contentTypes.length; i++) {
if (isJSON(contentTypes[i])) {
return toJSONSchema(cloneObject(content[contentTypes[i]].schema), {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { createOasForOperation } from '../../__fixtures__/create-oas.js';

describe('#getResponseAsJSONSchema() content type override', () => {
it('should respect the preferContentType option if provided', () => {
const oas = createOasForOperation({
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: { json: { type: 'string' } },
},
},
'application/xml': {
schema: {
type: 'object',
properties: { xml: { type: 'string' } },
},
},
},
},
},
});
const operation = oas.operation('/', 'get');

// Default behavior (prefers JSON)
const defaultSchema = operation.getResponseAsJSONSchema('200');
expect(defaultSchema[0].schema.properties.json).toBeDefined();

// With override
const xmlSchema = operation.getResponseAsJSONSchema('200', { preferContentType: 'application/xml' });
expect(xmlSchema[0].schema.properties.xml).toBeDefined();
});

it('should fall back to default logic if provided preferContentType does not exist', () => {
const oas = createOasForOperation({
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: { json: { type: 'string' } },
},
},
},
},
},
});
const operation = oas.operation('/', 'get');

const schema = operation.getResponseAsJSONSchema('200', { preferContentType: 'application/xml' });
expect(schema[0].schema.properties.json).toBeDefined();
});
});
110 changes: 110 additions & 0 deletions packages/oas/test/operation/selected-content-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { createOasForOperation } from '../__fixtures__/create-oas.js';

describe('Operation #selectedContentType', () => {
it('should allow setting and getting a selected content type', () => {
const oas = createOasForOperation({
requestBody: {
content: {
'application/json': { schema: { type: 'object' } },
'application/xml': { schema: { type: 'object' } },
},
},
});
const operation = oas.operation('/', 'get');

expect(operation.getContentType()).toBe('application/json');

operation.setContentType('application/xml');
expect(operation.getContentType()).toBe('application/xml');
});

it('should respect selected content type in getRequestBody()', () => {
const oas = createOasForOperation({
requestBody: {
description: 'Test Description',
content: {
'application/json': { schema: { type: 'object', properties: { json: { type: 'boolean' } } } },
'application/xml': { schema: { type: 'object', properties: { xml: { type: 'boolean' } } } },
},
},
});
const operation = oas.operation('/', 'get');

// Default (should be JSON)
const [defaultType] = operation.getRequestBody() as [string, any, string];
expect(defaultType).toBe('application/json');

// Select XML
operation.setContentType('application/xml');
const [selectedType, selectedSchema, description] = operation.getRequestBody() as [string, any, string];
expect(selectedType).toBe('application/xml');
expect(selectedSchema.schema.properties.xml).toBeDefined();
expect(description).toBe('Test Description');
});

it('should return false if selected content type does not exist in getRequestBody()', () => {
const oas = createOasForOperation({
requestBody: {
content: {
'application/json': { schema: { type: 'object' } },
},
},
});
const operation = oas.operation('/', 'get');

operation.setContentType('application/xml');
expect(operation.getRequestBody()).toBe(false);
});

it('should return available media types via getMediaTypes()', () => {
const oas = createOasForOperation({
requestBody: {
content: {
'application/json': { schema: { type: 'object' } },
'application/xml': { schema: { type: 'object' } },
},
},
responses: {
200: {
description: 'Success response',
content: {
'text/plain': { schema: { type: 'string' } },
},
},
},
});
const operation = oas.operation('/', 'get');

expect(operation.getMediaTypes()).toStrictEqual(['application/json', 'application/xml', 'text/plain']);
});

describe('no content type requirements', () => {
it('should return an empty array if no media types are defined anywhere', () => {
const oas = createOasForOperation({
responses: {
204: { description: 'No Content' },
},
});
const operation = oas.operation('/', 'get');

expect(operation.getMediaTypes()).toStrictEqual([]);
});

it('should not include Accept header if no response content exists', () => {
const oas = createOasForOperation({
requestBody: {
content: { 'application/json': {} },
},
responses: {
204: { description: 'No Content' },
},
});
const operation = oas.operation('/', 'get');

const headers = operation.getHeaders();
expect(headers.request).not.toContain('Accept');
expect(headers.request).toContain('Content-Type'); // Added because request body exists
});
});
});