-
|
Hi, I have a Nest.js application generating openapi specs. These are used to generate a typescript fetch client. For testing, I created a simple This is working like documented for single responses ( My current workaround right now is to apply the single entity transformer for the nested results manually. The question is, if this is a limitation by design of the transformers? Nest.js controller@ApiTags('examples')
@Controller('examples')
export class ExamplesController {
constructor(private readonly examplesService: ExamplesService) {}
@Get()
@ApiOperation({
summary: 'List all examples',
})
@PaginatedSwaggerDocs(Example, EXAMPLE_PAGINATION_CONFIG)
async findAll(@Paginate() query: PaginateQuery): Promise<Paginated<Example>> {
return this.examplesService.findAll(query);
}
@Get('first-three')
@ApiOperation({
summary: 'List first three examples',
})
@ApiOkResponse({
description: 'The found examples',
type: [Example],
})
async findFirstThree(): Promise<Example[]> {
return this.examplesService.findFirstThree();
}
@Get(':id')
@ApiOperation({
summary: 'Find an example by ID',
})
@ApiOkResponse({
description: 'The found example',
type: Example,
})
@ApiNotFoundResponse({
description: 'Example not found',
})
findOne(@Param('id') id: string) {
return this.examplesService.findOne(+id);
}
@Patch(':id')
@ApiOperation({
summary: 'Update an example by ID',
})
@ApiBadRequestResponse({
description: 'Invalid input data',
})
@ApiOkResponse({
description: 'The updated example',
type: Example,
})
@ApiNotFoundResponse({
description: 'Example not found',
})
update(@Param('id') id: string, @Body() updateExampleDto: Partial<Omit<Example, 'id'>>) {
return this.examplesService.update(+id, updateExampleDto);
}
}OpenAPI schema{
"openapi": "3.0.0",
"paths": {
"/examples": {
"get": {
"operationId": "findAll",
"parameters": [
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number to retrieve. If you provide invalid value the default page number will applied\n\n**Example:** 1\n\n\n**Default Value:** 1\n\n",
"schema": { "type": "number" }
},
{
"name": "limit",
"required": false,
"in": "query",
"description": "Number of records per page.\n\n\n**Example:** 20\n\n\n\n**Default Value:** 20\n\n\n\n**Max Value:** 100\n\n\nIf provided value is greater than max value, max value will be applied.\n",
"schema": { "type": "number" }
},
{
"name": "filter.name",
"required": false,
"in": "query",
"description": "Filter by name query param.\n\n**Format:** filter.name={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.name=$eq:John Doe\n\n**Available Operations**\n- $eq\n\n- $not\n\n- $and\n\n- $or",
"schema": { "type": "array", "items": { "type": "string" } }
},
{
"name": "filter.age",
"required": false,
"in": "query",
"description": "Filter by age query param.\n\n**Format:** filter.age={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.age=$btw:John Doe&filter.age=$contains:John Doe\n\n**Available Operations**\n- $eq\n\n- $gt\n\n- $gte\n\n- $in\n\n- $null\n\n- $lt\n\n- $lte\n\n- $btw\n\n- $ilike\n\n- $sw\n\n- $contains\n\n- $not\n\n- $and\n\n- $or",
"schema": { "type": "array", "items": { "type": "string" } }
},
{
"name": "sortBy",
"required": false,
"in": "query",
"description": "Parameter to sort by.\nTo sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting\n\n**Format:** {fieldName}:{DIRECTION}\n\n\n**Example:** sortBy=id:DESC&sortBy=name:DESC\n\n\n**Default Value:** id:DESC\n\n**Available Fields**\n- id\n\n- name\n\n- sex\n\n- age\n\n- active\n\n- birthdate\n",
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"id:ASC",
"id:DESC",
"name:ASC",
"name:DESC",
"sex:ASC",
"sex:DESC",
"age:ASC",
"age:DESC",
"active:ASC",
"active:DESC",
"birthdate:ASC",
"birthdate:DESC"
]
}
}
},
{
"name": "search",
"required": false,
"in": "query",
"description": "Search term to filter result values\n\n**Example:** John\n\n\n**Default Value:** No default value\n\n",
"schema": { "type": "string" }
},
{
"name": "searchBy",
"required": false,
"in": "query",
"description": "List of fields to search by term to filter result values\n\n**Example:** name,sex,age\n\n\n**Default Value:** By default all fields mentioned below will be used to search by term\n\n**Available Fields**\n- name\n\n- sex\n\n- age\n",
"schema": { "type": "array", "items": { "type": "string" } }
},
{
"name": "select",
"required": false,
"in": "query",
"description": "List of fields to select.\n\n**Example:** id,name,sex,age,active\n\n\n**Default Value:** By default all fields returns. If you want to select only some fields, provide them in query param\n\n",
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"allOf": [
{ "$ref": "#/components/schemas/PaginatedDocumented" },
{
"properties": {
"data": {
"type": "array",
"items": { "$ref": "#/components/schemas/Example" }
},
"meta": {
"properties": {
"select": {
"type": "array",
"items": {
"type": "string",
"enum": ["id", "name", "sex", "age", "active", "birthdate"]
}
},
"filter": {
"type": "object",
"properties": {
"name": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"age": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
}
}
}
}
}
}
}
]
}
}
}
}
},
"summary": "List all examples",
"tags": ["examples"]
}
},
"/examples/first-three": {
"get": {
"operationId": "findFirstThree",
"parameters": [],
"responses": {
"200": {
"description": "The found examples",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Example" } }
}
}
}
},
"summary": "List first three examples",
"tags": ["examples"]
}
},
"/examples/{id}": {
"get": {
"operationId": "findOne",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "The found example",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Example" } }
}
},
"404": { "description": "Example not found" }
},
"summary": "Find an example by ID",
"tags": ["examples"]
},
"patch": {
"operationId": "update",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "The updated example",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Example" } }
}
},
"400": { "description": "Invalid input data" },
"404": { "description": "Example not found" }
},
"summary": "Update an example by ID",
"tags": ["examples"]
}
}
},
"info": {
"title": "PitStop BFF API",
"description": "An API to serve the needs of the PitStop Frontend.",
"version": "1.0",
"contact": {}
},
"tags": [],
"servers": [],
"components": {
"schemas": {
"PaginatedMetaDocumented": {
"type": "object",
"properties": {
"itemsPerPage": { "type": "number", "title": "Number of items per page" },
"totalItems": { "type": "number", "title": "Total number of items" },
"currentPage": { "type": "number", "title": "Current requested page" },
"totalPages": { "type": "number", "title": "Total number of pages" },
"sortBy": {
"type": "array",
"title": "Sorting by columns",
"items": {
"type": "array",
"items": {
"oneOf": [{ "type": "string" }, { "type": "string", "enum": ["ASC", "DESC"] }]
}
}
},
"searchBy": {
"title": "Search by fields",
"type": "array",
"items": { "type": "string" }
},
"search": { "type": "string", "title": "Search term" },
"select": {
"title": "List of selected fields",
"type": "array",
"items": { "type": "string" }
},
"filter": {
"type": "object",
"title": "Filters that applied to the query",
"required": [],
"additionalProperties": false
}
},
"required": ["itemsPerPage", "totalItems", "currentPage", "totalPages"]
},
"PaginatedLinksDocumented": {
"type": "object",
"properties": {
"first": { "type": "string", "title": "Link to first page" },
"previous": { "type": "string", "title": "Link to previous page" },
"current": { "type": "string", "title": "Link to current page" },
"next": { "type": "string", "title": "Link to next page" },
"last": { "type": "string", "title": "Link to last page" }
}
},
"PaginatedDocumented": {
"type": "object",
"properties": {
"data": {
"title": "Array of entities",
"additionalProperties": false,
"type": "array",
"items": { "type": "object" }
},
"meta": {
"title": "Pagination Metadata",
"allOf": [{ "$ref": "#/components/schemas/PaginatedMetaDocumented" }]
},
"links": {
"title": "Links to pages",
"allOf": [{ "$ref": "#/components/schemas/PaginatedLinksDocumented" }]
}
},
"required": ["data", "meta", "links"]
},
"Example": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Unique identifier for the example" },
"name": { "type": "string", "description": "Name of the example" },
"active": { "type": "boolean", "description": "Whether the example is active" },
"sex": {
"type": "string",
"description": "Sex of the example",
"enum": ["male", "female", "other"]
},
"age": { "type": "number", "description": "Age of the example" },
"birthdate": {
"format": "date-time",
"type": "string",
"description": "Birthdate of the example"
}
},
"required": ["id", "name", "active", "sex", "age", "birthdate"]
}
}
}
}The generated transformersimport type { FindFirstThreeResponse, FindOneResponse, UpdateResponse } from './types.js';
const exampleSchemaResponseTransformer = (data: any) => {
data.birthdate = new Date(data.birthdate);
return data;
};
export const findFirstThreeResponseTransformer = async (
data: any
): Promise<FindFirstThreeResponse> => {
data = data.map((item: any) => exampleSchemaResponseTransformer(item));
return data;
};
export const findOneResponseTransformer = async (data: any): Promise<FindOneResponse> => {
data = exampleSchemaResponseTransformer(data);
return data;
};
export const updateResponseTransformer = async (data: any): Promise<UpdateResponse> => {
data = exampleSchemaResponseTransformer(data);
return data;
};Workaroundexport async function getExamples() {
const { data, error } = await findAll({ client });
if (data?.data !== undefined) {
data.data = await Promise.all(data.data.map(item => findOneResponseTransformer(item)));
}
return data;
} |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 2 replies
-
|
Hi @davidenke! I'm Dosu and I’m helping the openapi-ts team. Yes, this is a known limitation of the current transformer system: the generated transformers handle only the most common scenarios and do not automatically transform nested lists inside objects, such as paginated responses. That's why you need to manually map the entity transformer over the nested array—your workaround is the recommended approach for now. This behavior is by design and is documented as a limitation of the transformer plugin see docs. There's no indication that aligning or modifying your OpenAPI schema would enable automatic nested transformation. The Currently, the To reply, just mention @dosu. How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other |
Beta Was this translation helpful? Give feedback.
You're right—the documentation doesn't explicitly call out the limitation around nested arrays in paginated objects. It only states that transformers are generated for types defined through $ref and that union types aren't transformed. The fact that your paginated response uses $ref for the entity type would suggest it should work, but the transformer system currently generates a single transformer per operation response and only handles the most common scenarios. That means it doesn't recursively apply entity transformers to nested arrays inside objects like paginated responses, even if those arrays use $ref types. This is an implicit limitation of the current implementation, not an exp…