From a4de7deae3e66980d40c8d36a9b439379511de46 Mon Sep 17 00:00:00 2001 From: Johannes Reppin Date: Wed, 22 Jul 2026 13:44:12 +0200 Subject: [PATCH 1/2] MAke api fields optional and add guards to prevent null fields. added null check for attachments include to prevent http 400 for empty attachments. add include fields to DTO add defaults to fields to prevent null in API response. --- src/common/pipes/include-validation.pipe.ts | 15 ++- src/common/utils.ts | 10 ++ src/datasets/datasets-public.v4.controller.ts | 2 + src/datasets/datasets.v4.controller.ts | 2 + src/datasets/dto/output-dataset.dto.ts | 122 +++++++++++++++++- src/datasets/schemas/dataset.schema.ts | 16 +-- src/datasets/types/dataset-lookup.ts | 2 +- .../dto/output-origdatablock.dto.ts | 7 +- .../origdatablocks.v4.controller.ts | 2 + src/samples/dto/output-sample.dto.ts | 3 +- 10 files changed, 161 insertions(+), 20 deletions(-) diff --git a/src/common/pipes/include-validation.pipe.ts b/src/common/pipes/include-validation.pipe.ts index f6a2ebc97..7a1692c89 100644 --- a/src/common/pipes/include-validation.pipe.ts +++ b/src/common/pipes/include-validation.pipe.ts @@ -29,7 +29,9 @@ export class IncludeValidationPipe implements PipeTransform< ? inValue : isJsonString(inValue) ? JSON.parse(inValue ?? "{}").include - : Array(inValue); + : inValue.includes(",") + ? IncludeValidationPipe.splitCsv(inValue) + : Array(inValue); includeValueParsed?.map((field) => { let relationField = field; @@ -46,4 +48,15 @@ export class IncludeValidationPipe implements PipeTransform< return inValue; } + + /** + * Split a comma-separated string into individual relation values. + * Handles CSV-style serialization used by some OpenAPI client generators. + */ + static splitCsv(inValue: string): string[] { + return inValue + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } } diff --git a/src/common/utils.ts b/src/common/utils.ts index 5d2af5a41..3505df36b 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1340,6 +1340,16 @@ export function decodeMetadataKeyStrings(keys: string[]): string[] { return keys.map((key) => decodeURIComponentExtended(key)); } +export function filterNullFromArray( + value: (T | null | undefined)[] | undefined, +): T[] | undefined { + if (!value) return undefined; + const filtered = value.filter( + (item): item is T => item !== null && item !== undefined, + ); + return filtered.length > 0 ? filtered : undefined; +} + export function parseDate(dateString?: string): Date | undefined { if (!dateString) return undefined; const parsedDate = new Date(dateString); diff --git a/src/datasets/datasets-public.v4.controller.ts b/src/datasets/datasets-public.v4.controller.ts index c8771e0f0..afdea9849 100644 --- a/src/datasets/datasets-public.v4.controller.ts +++ b/src/datasets/datasets-public.v4.controller.ts @@ -303,6 +303,8 @@ export class DatasetsPublicV4Controller { type: String, required: false, isArray: true, + style: "form", + explode: true, }) async findByIdPublic( @Param("pid") id: string, diff --git a/src/datasets/datasets.v4.controller.ts b/src/datasets/datasets.v4.controller.ts index 6d005a78b..28be5bdd2 100644 --- a/src/datasets/datasets.v4.controller.ts +++ b/src/datasets/datasets.v4.controller.ts @@ -713,6 +713,8 @@ export class DatasetsV4Controller { type: String, required: false, isArray: true, + style: "form", + explode: true, }) async findById( @Req() request: Request, diff --git a/src/datasets/dto/output-dataset.dto.ts b/src/datasets/dto/output-dataset.dto.ts index 668c5eac1..8c5163454 100644 --- a/src/datasets/dto/output-dataset.dto.ts +++ b/src/datasets/dto/output-dataset.dto.ts @@ -1,8 +1,17 @@ -import { ApiProperty, PartialType } from "@nestjs/swagger"; +import { ApiProperty, PartialType, getSchemaPath } from "@nestjs/swagger"; import { CreateDatasetDto } from "./create-dataset.dto"; -import { IsDateString, IsString } from "class-validator"; -import { decodeScientificMetadataKeys } from "src/common/utils"; -import { Transform } from "class-transformer"; +import { IsArray, IsDateString, IsOptional, IsString } from "class-validator"; +import { + decodeScientificMetadataKeys, + filterNullFromArray, +} from "src/common/utils"; +import { Transform, Type } from "class-transformer"; +import { OutputOrigDatablockDto } from "src/origdatablocks/dto/output-origdatablock.dto"; +import { Datablock } from "src/datablocks/schemas/datablock.schema"; +import { OutputAttachmentV4Dto } from "src/attachments/dto/output-attachment.v4.dto"; +import { Instrument } from "src/instruments/schemas/instrument.schema"; +import { ProposalClass } from "src/proposals/schemas/proposal.schema"; +import { OutputSampleDto } from "src/samples/dto/output-sample.dto"; export class OutputDatasetDto extends CreateDatasetDto { @ApiProperty({ @@ -51,12 +60,13 @@ export class OutputDatasetDto extends CreateDatasetDto { @ApiProperty({ type: String, - required: true, + required: false, description: "Version of the API used when the dataset was created or last updated. API version is defined in code for each release. Managed by the system.", }) + @IsOptional() @IsString() - version: string; + version?: string; @ApiProperty({ type: Object, @@ -66,6 +76,106 @@ export class OutputDatasetDto extends CreateDatasetDto { }) @Transform(({ value }) => decodeScientificMetadataKeys(value)) declare scientificMetadata?: Record; + + @Transform(({ value }) => filterNullFromArray(value)) + declare keywords?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare sharedWith?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare proposalIds?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare sampleIds?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare instrumentIds?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare inputDatasets?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare usedSoftware?: string[]; + + @Transform(({ value }) => filterNullFromArray(value)) + declare principalInvestigators?: string[]; + + // --------------------------------------------------------------------------- + // Includable relation fields — populated via ?include query parameter + // --------------------------------------------------------------------------- + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(OutputOrigDatablockDto) }, + required: false, + description: + "Containers that list all files and their attributes which make up a dataset. Included when ?include=origdatablocks is used.", + }) + @IsOptional() + @IsArray() + @Type(() => OutputOrigDatablockDto) + origdatablocks?: OutputOrigDatablockDto[]; + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(Datablock) }, + required: false, + description: + "Archived file blocks with checksums. Included when ?include=datablocks is used.", + }) + @IsOptional() + @IsArray() + @Type(() => Datablock) + datablocks?: Datablock[]; + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(OutputAttachmentV4Dto) }, + required: false, + description: + "Small attachments such as preview images. Included when ?include=attachments is used.", + }) + @IsOptional() + @IsArray() + @Type(() => OutputAttachmentV4Dto) + attachments?: OutputAttachmentV4Dto[]; + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(Instrument) }, + required: false, + description: + "Instruments associated with the dataset. Included when ?include=instruments is used.", + }) + @IsOptional() + @IsArray() + @Type(() => Instrument) + instruments?: Instrument[]; + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(ProposalClass) }, + required: false, + description: + "Proposals associated with the dataset. Included when ?include=proposals is used.", + }) + @IsOptional() + @IsArray() + @Type(() => ProposalClass) + proposals?: ProposalClass[]; + + @ApiProperty({ + type: "array", + items: { $ref: getSchemaPath(OutputSampleDto) }, + required: false, + description: + "Samples associated with the dataset. Included when ?include=samples is used.", + }) + @IsOptional() + @IsArray() + @Type(() => OutputSampleDto) + samples?: OutputSampleDto[]; } export class PartialOutputDatasetDto extends PartialType(OutputDatasetDto) {} diff --git a/src/datasets/schemas/dataset.schema.ts b/src/datasets/schemas/dataset.schema.ts index 9a8af6c39..fa7bdfab1 100644 --- a/src/datasets/schemas/dataset.schema.ts +++ b/src/datasets/schemas/dataset.schema.ts @@ -215,8 +215,8 @@ export class DatasetClass extends OwnableClass { description: "Array of tags associated with the meaning or contents of this dataset. Values should ideally come from defined vocabularies, taxonomies, ontologies or knowledge graphs.", }) - @Prop({ type: [String], required: false }) - keywords: string[]; + @Prop({ type: [String], required: false, default: [] }) + keywords: string[] = []; @ApiProperty({ type: String, @@ -366,7 +366,7 @@ export class DatasetClass extends OwnableClass { description: "First and last name of principal investigator(s). Multiple PIs can be provided as separate strings in the array. This field is required if the dataset is a Raw dataset.", }) - @Prop({ type: [String], required: false }) + @Prop({ type: [String], required: false, default: [] }) principalInvestigators?: string[]; @ApiProperty({ @@ -420,7 +420,7 @@ export class DatasetClass extends OwnableClass { description: "The ID of the proposal to which the dataset belongs to and it has been acquired under.", }) - @Prop({ type: [String], ref: "Proposal", required: false }) + @Prop({ type: [String], ref: "Proposal", required: false, default: [] }) proposalIds?: string[]; @ApiProperty({ @@ -429,7 +429,7 @@ export class DatasetClass extends OwnableClass { description: "Single ID or array of IDS of the samples used when collecting the data.", }) - @Prop({ type: [String], ref: "Sample", required: false }) + @Prop({ type: [String], ref: "Sample", required: false, default: [] }) sampleIds?: string[]; @ApiProperty({ @@ -438,7 +438,7 @@ export class DatasetClass extends OwnableClass { description: "Id of the instrument or array of IDS of the instruments where the data contained in this dataset was created/acquired.", }) - @Prop({ type: [String], ref: "Instrument", required: false }) + @Prop({ type: [String], ref: "Instrument", required: false, default: [] }) instrumentIds?: string[]; @ApiProperty({ @@ -447,7 +447,7 @@ export class DatasetClass extends OwnableClass { description: "Array of input dataset identifiers used in producing the derived dataset. Ideally these are the global identifier to existing datasets inside this or federated data catalogs.", }) - @Prop({ type: [String], required: false }) + @Prop({ type: [String], required: false, default: [] }) inputDatasets?: string[]; @ApiProperty({ @@ -456,7 +456,7 @@ export class DatasetClass extends OwnableClass { description: "A list of links to software repositories which uniquely identifies the pieces of software, including versions, used for yielding the derived data.", }) - @Prop({ type: [String], required: false }) + @Prop({ type: [String], required: false, default: [] }) usedSoftware?: string[]; @ApiProperty({ diff --git a/src/datasets/types/dataset-lookup.ts b/src/datasets/types/dataset-lookup.ts index 758aa8fa8..f73c02579 100644 --- a/src/datasets/types/dataset-lookup.ts +++ b/src/datasets/types/dataset-lookup.ts @@ -70,7 +70,7 @@ export const DATASET_LOOKUP_FIELDS: Record< $expr: { $anyElementTrue: { $map: { - input: "$relationships", + input: { $ifNull: ["$relationships", []] }, as: "relationship", in: { $and: [ diff --git a/src/origdatablocks/dto/output-origdatablock.dto.ts b/src/origdatablocks/dto/output-origdatablock.dto.ts index 42be473ad..c4663a1d9 100644 --- a/src/origdatablocks/dto/output-origdatablock.dto.ts +++ b/src/origdatablocks/dto/output-origdatablock.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, PartialType } from "@nestjs/swagger"; import { CreateOrigDatablockDto } from "./create-origdatablock.dto"; -import { IsDateString, IsString } from "class-validator"; +import { IsDateString, IsOptional, IsString } from "class-validator"; export class OutputOrigDatablockDto extends CreateOrigDatablockDto { @ApiProperty({ @@ -41,12 +41,13 @@ export class OutputOrigDatablockDto extends CreateOrigDatablockDto { @ApiProperty({ type: String, - required: true, + required: false, description: "Version of the API used when the origdatablock was created or last updated. API version is defined in code for each release. Managed by the system.", }) + @IsOptional() @IsString() - version: string; + version?: string; } export class PartialOutputOrigDatablockDto extends PartialType( diff --git a/src/origdatablocks/origdatablocks.v4.controller.ts b/src/origdatablocks/origdatablocks.v4.controller.ts index 66b874787..644a52192 100644 --- a/src/origdatablocks/origdatablocks.v4.controller.ts +++ b/src/origdatablocks/origdatablocks.v4.controller.ts @@ -748,6 +748,8 @@ export class OrigDatablocksV4Controller { type: String, required: false, isArray: true, + style: "form", + explode: true, }) async findById( @Req() request: Request, diff --git a/src/samples/dto/output-sample.dto.ts b/src/samples/dto/output-sample.dto.ts index e92f9deb1..f5688cdf1 100644 --- a/src/samples/dto/output-sample.dto.ts +++ b/src/samples/dto/output-sample.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsDateString, IsString } from "class-validator"; +import { IsDateString, IsOptional, IsString } from "class-validator"; import { CreateSampleDto } from "./create-sample.dto"; import { Transform } from "class-transformer"; import { decodeScientificMetadataKeys } from "src/common/utils"; @@ -47,6 +47,7 @@ export class OutputSampleDto extends CreateSampleDto { description: "Version of the API used when the dataset was created or last updated. API version is defined in code for each release. Managed by the system.", }) + @IsOptional() @IsString() version?: string; From 480e40cbf6394146a9c67ecff114f5f8808af51b Mon Sep 17 00:00:00 2001 From: Johannes Reppin Date: Thu, 23 Jul 2026 10:31:15 +0000 Subject: [PATCH 2/2] do not return undefined. --- src/common/utils.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/common/utils.ts b/src/common/utils.ts index 3505df36b..09b2e38d2 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1342,12 +1342,9 @@ export function decodeMetadataKeyStrings(keys: string[]): string[] { export function filterNullFromArray( value: (T | null | undefined)[] | undefined, -): T[] | undefined { - if (!value) return undefined; - const filtered = value.filter( - (item): item is T => item !== null && item !== undefined, - ); - return filtered.length > 0 ? filtered : undefined; +): T[] { + if (!value) return []; + return value.filter((item): item is T => item !== null && item !== undefined); } export function parseDate(dateString?: string): Date | undefined {