Skip to content

POC: add complete endpoint in instruments that returns paginated data with totalCount #1817

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8988c62
metadata quantity_range valueSI conversion support and test
martin-trajanovski Mar 5, 2025
dcbc26b
Merge branch 'master' into SWAP-4564-add-range-metadata-types
martin-trajanovski Mar 5, 2025
0daefe4
add extra check for quantity_range length of the value
martin-trajanovski Mar 7, 2025
84a96cc
Merge branch 'SWAP-4564-add-range-metadata-types' of https://github.c…
martin-trajanovski Mar 7, 2025
4bafe29
Merge branch 'master' into SWAP-4564-add-range-metadata-types
martin-trajanovski Mar 19, 2025
9f29cd1
feat: add count endpoint for instruments
martin-trajanovski Mar 20, 2025
655deb4
Merge branch 'master' into SWAP-4586-instruments-table-replacement
martin-trajanovski Mar 21, 2025
529607d
Merge branch 'master' into SWAP-4586-instruments-table-replacement
martin-trajanovski Mar 21, 2025
651743c
Merge branch 'master' into SWAP-4586-instruments-table-replacement
martin-trajanovski Mar 24, 2025
25b8f39
Merge branch 'master' into SWAP-4586-instruments-table-replacement
martin-trajanovski Mar 25, 2025
2f1f98c
Merge branch 'master' into SWAP-4586-instruments-table-replacement
martin-trajanovski Mar 31, 2025
caf87e9
start introducing instrument complete endpoint to include data and to…
martin-trajanovski Mar 31, 2025
b4b8a17
add findAllComplete in the instruments which is better documented and…
martin-trajanovski Apr 4, 2025
a65d2e1
Merge branch 'master' into SWAP-4640-find-all-instruments-complete
martin-trajanovski Apr 4, 2025
8e5c52c
Merge branch 'master' into SWAP-4640-find-all-instruments-complete
martin-trajanovski Apr 29, 2025
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
1 change: 1 addition & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import { MetricsModule } from "./metrics/metrics.module";
MongooseModule.forRootAsync({
useFactory: async (configService: ConfigService) => ({
uri: configService.get<string>("mongodbUri"),
sanitizeFilter: true,
}),
inject: [ConfigService],
}),
Expand Down
35 changes: 35 additions & 0 deletions src/common/decorators/api-data-count-response.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { applyDecorators, Type } from "@nestjs/common";
import { HttpStatus } from "@nestjs/common/enums";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { DataCountOutputDto } from "../types";

export const ApiDataCountResponse = <DataDto extends Type<unknown>>(
dataDto: DataDto,
description = "List of items with total count. The list is limited by the limit and offset parameters.",
statusCode = HttpStatus.OK,
) =>
applyDecorators(
ApiExtraModels(DataCountOutputDto, dataDto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(DataCountOutputDto) },
{
properties: {
data: {
type: "array",
items: { $ref: getSchemaPath(dataDto) },
},
totalCount: {
type: "number",
default: 0,
},
},
},
],
required: ["data", "totalCount"],
},
status: statusCode,
description: description,
}),
);
13 changes: 13 additions & 0 deletions src/common/interfaces/common.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ export interface IFilters<T, Y = null> {
limits?: ILimitsFilter;
}

export interface IFiltersV4<T> {
where?: FilterQuery<T>;
include?: string[];
fields?: string[];
limits?: ILimitsFilterV4;
}

export interface ILimitsFilterV4 {
limit: number;
skip: number;
sort: Record<string, "asc" | "desc">;
}

export interface IFacets<T> {
fields?: T;
facets?: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const FILTERS: Record<"limits" | "fields" | "where" | "include", object> = {
where: {
type: "object",
example: {
datasetName: { $regex: "Dataset", $options: "i" },
field1: { $regex: "Test", $options: "i" },
},
},
include: {
Expand All @@ -22,24 +22,24 @@ const FILTERS: Record<"limits" | "fields" | "where" | "include", object> = {
type: "array",
items: {
type: "string",
example: "datasetName",
example: "field1",
},
},
limits: {
type: "object",
properties: {
limit: {
type: "number",
example: 10,
},
skip: {
type: "number",
example: 0,
},
limit: {
type: "number",
example: 10,
},
sort: {
type: "object",
properties: {
datasetName: {
field1: {
type: "string",
example: "asc | desc",
},
Expand All @@ -54,7 +54,7 @@ const FILTERS: Record<"limits" | "fields" | "where" | "include", object> = {
* But we want to have it when we run the application as it improves swagger documentation and usage a lot.
* We use "content" property as it is described in the swagger specification: https://swagger.io/docs/specification/v3_0/describing-parameters/#schema-vs-content:~:text=explode%3A%20false-,content,-is%20used%20in
*/
export const getSwaggerDatasetFilterContent = (
export const getSwaggerFilterContent = (
filtersToInclude: Record<keyof typeof FILTERS, boolean> = {
where: true,
include: true,
Expand Down
6 changes: 6 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,9 @@ export class IsValidResponse {
@ApiProperty({ type: Boolean })
isvalid: boolean;
}

export class DataCountOutputDto<T> {
data: T[];

totalCount: number;
}
42 changes: 42 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
IAxiosError,
IFilters,
ILimitsFilter,
ILimitsFilterV4,
IScientificFilter,
} from "./interfaces/common.interface";
import { ScientificRelation } from "./scientific-relation.enum";
import { DatasetType } from "src/datasets/types/dataset-type.enum";
import { isEmpty } from "lodash";

// add Å to mathjs accepted units as equivalent to angstrom
const isAlphaOriginal = Unit.isValidAlpha;
Expand Down Expand Up @@ -364,6 +366,46 @@ export const parsePipelineProjection = (fieldsProjection: string[]) => {
return pipelineProjection;
};

export const buildDataCountFacetPipeline = (
limits: ILimitsFilterV4 | undefined,
fields: string[] | undefined,
) => {
const skip = limits?.skip || 0;
const limit = limits?.limit || 10;

const facet: PipelineStage.Facet = {
$facet: {
data: [{ $skip: skip }, { $limit: limit }],
count: [{ $count: "totalCount" }],
},
};

if (limits?.sort && !isEmpty(limits?.sort)) {
const sortParsed = parsePipelineSort(limits.sort);

facet.$facet.data.push({ $sort: sortParsed });
}

if (fields) {
const project: PipelineStage.Project["$project"] =
parsePipelineProjection(fields);

facet.$facet.data.push({ $project: project });
}

return facet;
};

// NOTE: This returns default projection of the data and totalCount fields for findAll complete endpoints
export const getDefaultDataCountProject = (): PipelineStage.Project => {
return {
$project: {
data: 1,
totalCount: { $arrayElemAt: ["$count.totalCount", 0] },
},
};
};

export const parseLimitFiltersForPipeline = (
limits: ILimitsFilter | undefined,
): PipelineStage[] => {
Expand Down
13 changes: 4 additions & 9 deletions src/datasets/datasets-public.v4.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ import {
import { DatasetLookupKeysEnum } from "./types/dataset-lookup";
import { IncludeValidationPipe } from "./pipes/include-validation.pipe";
import { FilterValidationPipe } from "./pipes/filter-validation.pipe";
import { getSwaggerDatasetFilterContent } from "./types/dataset-filter-content";
import { AllowAny } from "src/auth/decorators/allow-any.decorator";
import { getSwaggerFilterContent } from "src/common/swagger-filter-content";

@ApiExtraModels(HistoryClass, TechniqueClass, RelationshipClass)
@ApiTags("datasets public v4")
Expand Down Expand Up @@ -58,7 +58,7 @@ export class DatasetsPublicV4Controller {
"Database filters to apply when retrieving the public datasets",
required: false,
type: String,
content: getSwaggerDatasetFilterContent(),
content: getSwaggerFilterContent(),
})
@ApiResponse({
status: HttpStatus.OK,
Expand Down Expand Up @@ -171,12 +171,7 @@ export class DatasetsPublicV4Controller {
description: "Database filters to apply when retrieving public dataset",
required: true,
type: String,
content: getSwaggerDatasetFilterContent({
where: true,
include: true,
fields: true,
limits: true,
}),
content: getSwaggerFilterContent(),
})
@ApiResponse({
status: HttpStatus.OK,
Expand Down Expand Up @@ -208,7 +203,7 @@ export class DatasetsPublicV4Controller {
"Database filters to apply when retrieving count for public datasets",
required: false,
type: String,
content: getSwaggerDatasetFilterContent({
content: getSwaggerFilterContent({
where: true,
include: false,
fields: false,
Expand Down
13 changes: 4 additions & 9 deletions src/datasets/datasets.v4.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ import { DatasetLookupKeysEnum } from "./types/dataset-lookup";
import { IncludeValidationPipe } from "./pipes/include-validation.pipe";
import { PidValidationPipe } from "./pipes/pid-validation.pipe";
import { FilterValidationPipe } from "./pipes/filter-validation.pipe";
import { getSwaggerDatasetFilterContent } from "./types/dataset-filter-content";
import { plainToInstance } from "class-transformer";
import { getSwaggerFilterContent } from "src/common/swagger-filter-content";

@ApiBearerAuth()
@ApiExtraModels(
Expand Down Expand Up @@ -355,7 +355,7 @@ export class DatasetsV4Controller {
description: "Database filters to apply when retrieving datasets",
required: false,
type: String,
content: getSwaggerDatasetFilterContent(),
content: getSwaggerFilterContent(),
})
@ApiResponse({
status: HttpStatus.OK,
Expand Down Expand Up @@ -523,12 +523,7 @@ export class DatasetsV4Controller {
description: "Database filters to apply when retrieving dataset",
required: true,
type: String,
content: getSwaggerDatasetFilterContent({
where: true,
include: true,
fields: true,
limits: true,
}),
content: getSwaggerFilterContent(),
})
@ApiResponse({
status: HttpStatus.OK,
Expand Down Expand Up @@ -566,7 +561,7 @@ export class DatasetsV4Controller {
description: "Database filters to apply when retrieving count for datasets",
required: false,
type: String,
content: getSwaggerDatasetFilterContent({
content: getSwaggerFilterContent({
where: true,
include: false,
fields: false,
Expand Down
34 changes: 32 additions & 2 deletions src/instruments/instruments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import { AppAbility } from "src/casl/casl-ability.factory";
import { Action } from "src/casl/action.enum";
import { Instrument, InstrumentDocument } from "./schemas/instrument.schema";
import { FormatPhysicalQuantitiesInterceptor } from "src/common/interceptors/format-physical-quantities.interceptor";
import { IFilters } from "src/common/interfaces/common.interface";
import { IFilters, IFiltersV4 } from "src/common/interfaces/common.interface";
import {
filterDescription,
filterExample,
replaceLikeOperator,
} from "src/common/utils";
import { CountApiResponse } from "src/common/types";
import { CountApiResponse, DataCountOutputDto } from "src/common/types";
import { getSwaggerFilterContent } from "src/common/swagger-filter-content";
import { ApiDataCountResponse } from "src/common/decorators/api-data-count-response.decorator";

@ApiBearerAuth()
@ApiTags("instruments")
Expand Down Expand Up @@ -89,6 +91,34 @@ export class InstrumentsController {
return this.instrumentsService.findAll(instrumentFilter);
}

@UseGuards(PoliciesGuard)
@CheckPolicies("instruments", (ability: AppAbility) =>
ability.can(Action.InstrumentRead, Instrument),
)
@Get("/complete")
@ApiQuery({
name: "filters",
description: "Database filters to apply when retrieve all instruments",
required: false,
type: String,
content: getSwaggerFilterContent({
where: true,
include: false,
fields: true,
limits: true,
}),
})
@ApiDataCountResponse(Instrument, "Return the instruments requested")
async findAllComplete(
@Query("filters") filters?: string,
): Promise<DataCountOutputDto<Instrument>> {
const parsedFilters: IFiltersV4<InstrumentDocument> = JSON.parse(
filters ?? "{}",
);

return this.instrumentsService.findAllComplete(parsedFilters);
}

@UseGuards(PoliciesGuard)
@CheckPolicies("instruments", (ability: AppAbility) =>
ability.can(Action.InstrumentRead, Instrument),
Expand Down
29 changes: 25 additions & 4 deletions src/instruments/instruments.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Injectable } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { FilterQuery, Model } from "mongoose";
import { IFilters } from "src/common/interfaces/common.interface";
import { CountApiResponse } from "src/common/types";
import { parseLimitFilters } from "src/common/utils";
import { FilterQuery, Model, PipelineStage } from "mongoose";
import { IFilters, IFiltersV4 } from "src/common/interfaces/common.interface";
import { CountApiResponse, DataCountOutputDto } from "src/common/types";
import {
buildDataCountFacetPipeline,
getDefaultDataCountProject,
parseLimitFilters,
} from "src/common/utils";
import { CreateInstrumentDto } from "./dto/create-instrument.dto";
import { PartialUpdateInstrumentDto } from "./dto/update-instrument.dto";
import { Instrument, InstrumentDocument } from "./schemas/instrument.schema";
Expand Down Expand Up @@ -37,6 +41,23 @@ export class InstrumentsService {
return instruments;
}

async findAllComplete(
filter: IFiltersV4<InstrumentDocument>,
): Promise<DataCountOutputDto<Instrument>> {
const $match: PipelineStage.Match = { $match: filter.where ?? {} };

const $facet = buildDataCountFacetPipeline(filter.limits, filter.fields);

const $project = getDefaultDataCountProject();

const pipeline: PipelineStage[] = [$match, $facet, $project];

const [result] = await this.instrumentModel
.aggregate<DataCountOutputDto<Instrument>>(pipeline)
.exec();

return { data: result.data, totalCount: result.totalCount || 0 };
}
async count(filter: IFilters<InstrumentDocument>): Promise<CountApiResponse> {
const whereFilter: FilterQuery<InstrumentDocument> = filter.where ?? {};

Expand Down
Loading