From 5201ce8a1a4484c28584140de104b53f7f7239f1 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Thu, 22 Aug 2024 10:42:17 +0200 Subject: [PATCH] feat(Models): introduce Query Models and List Models --- .pnp.cjs | 1 + .../src/types/extractTotalCountHeader.ts | 38 ++++++++ packages/commons/src/types/index.ts | 1 + packages/mittwald/src/index.ts | 6 +- packages/models/README.md | 66 +++++++++++--- packages/models/package.json | 1 + .../app/AppInstallation/AppInstallation.ts | 86 ++++++++++++++++-- .../src/app/AppInstallation/behaviors/api.ts | 11 ++- .../app/AppInstallation/behaviors/types.ts | 7 +- .../models/src/app/AppInstallation/types.ts | 2 +- packages/models/src/base/ListDataModel.ts | 9 ++ packages/models/src/base/ListQueryModel.ts | 11 +++ packages/models/src/base/types.ts | 5 + packages/models/src/config/config.ts | 2 + .../models/src/customer/Customer/Customer.ts | 86 ++++++++++++++++-- .../src/customer/Customer/behaviors/api.ts | 11 ++- .../src/customer/Customer/behaviors/types.ts | 7 +- .../models/src/customer/Customer/types.ts | 2 +- packages/models/src/domain/Ingress/Ingress.ts | 86 +++++++++++++++++- .../src/domain/Ingress/behaviors/api.ts | 12 ++- .../src/domain/Ingress/behaviors/types.ts | 7 +- packages/models/src/domain/Ingress/types.ts | 13 ++- .../models/src/project/Project/Project.ts | 91 ++++++++++++++++++- .../src/project/Project/behaviors/api.ts | 11 ++- .../src/project/Project/behaviors/inMem.ts | 7 +- .../src/project/Project/behaviors/types.ts | 7 +- packages/models/src/project/Project/types.ts | 12 ++- packages/models/src/server/Server/Server.ts | 86 +++++++++++++++++- .../models/src/server/Server/behaviors/api.ts | 6 +- .../src/server/Server/behaviors/types.ts | 11 ++- packages/models/src/server/Server/types.ts | 10 +- yarn.lock | 1 + 32 files changed, 637 insertions(+), 75 deletions(-) create mode 100644 packages/commons/src/types/extractTotalCountHeader.ts create mode 100644 packages/models/src/base/ListDataModel.ts create mode 100644 packages/models/src/base/ListQueryModel.ts diff --git a/.pnp.cjs b/.pnp.cjs index 12919502..24719482 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2135,6 +2135,7 @@ const RAW_RUNTIME_STATE = ["eslint-plugin-json", "npm:3.1.0"],\ ["eslint-plugin-prettier", "virtual:36a01d8083315b8a6e8362097258ea8bc0f9dfb672cb210742e054760850c673a1038f542a6b7156397b5275ace8ee0482231cac5e8898044fa1a1c29f78ee5b#npm:5.2.1"],\ ["jest", "virtual:b2e857f8c518119e848cf4ef51cff2bf36fb4db0f8e551e1de9a65b88f5466b35ebea1913543d6258bb39baec552d66e8e4c2e8ae0858f2f3f9bf35009befb70#npm:29.7.0"],\ + ["object-code", "npm:1.3.3"],\ ["polytype", "npm:0.17.0"],\ ["prettier", "npm:3.3.3"],\ ["react", "npm:18.3.1"],\ diff --git a/packages/commons/src/types/extractTotalCountHeader.ts b/packages/commons/src/types/extractTotalCountHeader.ts new file mode 100644 index 00000000..5edd0069 --- /dev/null +++ b/packages/commons/src/types/extractTotalCountHeader.ts @@ -0,0 +1,38 @@ +import { ApiClientError } from "../core/index.js"; +import assertStatus from "./assertStatus.js"; +import { AxiosHeaders } from "axios"; +import { Response } from "./Response.js"; + +const headerName = "x-pagination-totalcount"; +const baseError = `Could not get header ${headerName}`; + +export const extractTotalCountHeader = (response: Response): number => { + assertStatus(response, 200); + + if (!(response.headers instanceof AxiosHeaders)) { + throw ApiClientError.fromResponse( + `${baseError}: Expected headers to be of type AxiosHeaders`, + response, + ); + } + + const headerContent = response.headers.get(headerName); + + if (typeof headerContent !== "string") { + throw ApiClientError.fromResponse( + `${baseError}: value is not of type string (is ${typeof headerContent} instead)`, + response, + ); + } + + const asNumber = Number.parseInt(headerContent); + + if (isNaN(asNumber)) { + throw ApiClientError.fromResponse( + `${baseError}: value is not a valid number (${typeof asNumber})`, + response, + ); + } + + return asNumber; +}; diff --git a/packages/commons/src/types/index.ts b/packages/commons/src/types/index.ts index 1c4c0db7..76a60414 100644 --- a/packages/commons/src/types/index.ts +++ b/packages/commons/src/types/index.ts @@ -6,3 +6,4 @@ export * from "./http.js"; export * from "./simplify.js"; export * from "./assertStatus.js"; export * from "./assertOneOfStatus.js"; +export * from "./extractTotalCountHeader.js"; diff --git a/packages/mittwald/src/index.ts b/packages/mittwald/src/index.ts index af17f616..951b09fc 100644 --- a/packages/mittwald/src/index.ts +++ b/packages/mittwald/src/index.ts @@ -1,3 +1,7 @@ -export { assertStatus, assertOneOfStatus } from "@mittwald/api-client-commons"; +export { + assertStatus, + assertOneOfStatus, + extractTotalCountHeader, +} from "@mittwald/api-client-commons"; export { MittwaldAPIClient as MittwaldAPIV2Client } from "./v2/default.js"; export type { MittwaldAPIV2 } from "./generated/v2/types.js"; diff --git a/packages/models/README.md b/packages/models/README.md index a510a4a7..4c38ff8e 100644 --- a/packages/models/README.md +++ b/packages/models/README.md @@ -60,10 +60,10 @@ await projectRef.updateDescription("My new description!"); const server = project.server; // List all projects of this server -const serversProjects = await server.listProjects(); +const serversProjects = await server.projects.execute(); // List all projects -const allProjects = await Project.list(); +const allProjects = await Project.query().execute(); // Iterate over project List Models for (const project of serversProjects) { @@ -105,10 +105,10 @@ const anotherDetailedProject = projectRef.getDetailed.use(); const server = project.server; // List all projects of this server -const serversProjects = server.listProjects.use(); +const serversProjects = server.projects.execute.use(); // List all projects -const allProjects = Project.list.use(); +const allProjects = Project.query().execute.use(); ``` ## Immutability and state updates @@ -190,10 +190,55 @@ model operations often just need the ID and some input data (deleting, renaming, should be used as a return type for newly created models or for linked models. To get the actual Detailed Model, Reference Models _must_ have a -`function getDetailed(): Promise` method. +`function getDetailed(): Promise` and +`function findDetailed(): Promise` method. Consider extending the Reference Model when implementing the Entry-Point Model. +#### Query Models + +Querying models usually requires a query object – or short query. The query +mostly includes pagination settings like `limit`, `skip` or `page`. It may also +include filters like `fromDate` or `toDate`, and filters to other models like +`customerId`. + +A Query Model represents a specific query to a specific model and should include +the following methods: + +- `execute()`: executes the query and returns the respective List Model +- `refine(overrideQuery)`: creates a new Query Model with a refined query object +- `getTotalCount()`: gets the total count of the query (executes the query with + `limit: 0`) + +When a model supports queries, it should provide a static `query()` method to +create the respective Query Model. + +When a model is used as a query parameters in a Query Model, the model should +have a property in its Reference Model for this Query Model. See the following +example: + +```typescript +class Server { + public readonly projects: ProjectsListQuery; + + public constructor(id: string) { + this.projects = new ProjectListQuery({ + server: this, + }); + } +} +``` + +#### List Models + +List Models are the result of a Query Model execution. A List Model includes + +- a list of the respective List Models, limited by the pagination configuration +- the available total count (useful to implement pagination or count data) + +List Models should extend their respective Query Model, because it might be +helpful to also call `refine()` on an already executed query. + #### Implementation details When implementing shared functionality, like in the Common Models, you can use @@ -204,7 +249,7 @@ implementation examples. #### Entry-Point Model Provide a single model (name it `[Model]`) as an entry point for all different -model types (detailed, list, ...). As a convention provide a default export for +model types (detailed, query, ...). As a convention provide a default export for this model. ### Use the correct verbs @@ -221,9 +266,9 @@ method. The get method should return the desired object or throw an `ObjectNotFoundError`. You can use the `find` method and assert the existence with the `assertObjectFound` function. -#### `list` +#### `query` -When a list of objects should be loaded use a `list` method. It may support a +When a list of objects should be queried use a `query` method. It may support a `query` parameter to filter the result by given criteria. #### `create` @@ -234,9 +279,8 @@ return a reference of the created resource. ### Accessing "linked" models Most of the models are part of a larger model tree. Models should provide -methods to get the parent and child models, like `project.getServer()`, -`server.listProjects()` or `server.getCustomer()`. Use `get`, `list` or `find` -prefixes as described above. +properties to get the parent and child models, like `project.server`, +`server.projects` or `server.customer`. #### Use Reference Models resp. Entry-Point Models when possible! diff --git a/packages/models/package.json b/packages/models/package.json index 396ff42e..24c8216a 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -41,6 +41,7 @@ "dependencies": { "@mittwald/api-client": "workspace:^", "another-deep-freeze": "^1.0.0", + "object-code": "^1.3.3", "polytype": "^0.17.0", "type-fest": "^4.23.0" }, diff --git a/packages/models/src/app/AppInstallation/AppInstallation.ts b/packages/models/src/app/AppInstallation/AppInstallation.ts index e6302ef4..f355acfa 100644 --- a/packages/models/src/app/AppInstallation/AppInstallation.ts +++ b/packages/models/src/app/AppInstallation/AppInstallation.ts @@ -6,10 +6,13 @@ import { ReferenceModel } from "../../base/ReferenceModel.js"; import type { AsyncResourceVariant } from "../../lib/provideReact.js"; import { provideReact } from "../../lib/provideReact.js"; import { - AppInstallationListItemData, AppInstallationData, - AppInstallationListQuery, + AppInstallationListItemData, + AppInstallationListQueryData, } from "./types.js"; +import { ListQueryModel } from "../../base/ListQueryModel.js"; +import { ListDataModel } from "../../base/ListDataModel.js"; +import { Project } from "../../project/index.js"; export class AppInstallation extends ReferenceModel { public static find = provideReact( @@ -33,16 +36,19 @@ export class AppInstallation extends ReferenceModel { return new AppInstallation(id); } + public query(project: Project, query: AppInstallationListQueryData = {}) { + return new AppInstallationListQuery(project, query); + } + + /** @deprecated: use query() or project.appInstallations */ public static list = provideReact( async ( projectId: string, - query: AppInstallationListQuery = {}, - ): Promise> => { - const data = await config.behaviors.appInstallation.list( - projectId, - query, - ); - return data.map((d) => new AppInstallationListItem(d)); + query: AppInstallationListQueryData = {}, + ): Promise>> => { + return new AppInstallationListQuery(Project.ofId(projectId), query) + .execute() + .then((r) => r.items); }, ); @@ -84,3 +90,65 @@ export class AppInstallationListItem extends classes( super([data], [data]); } } + +export class AppInstallationListQuery extends ListQueryModel { + public readonly project: Project; + + public constructor( + project: Project, + query: AppInstallationListQueryData = {}, + ) { + super(query); + this.project = project; + } + + public refine(query: AppInstallationListQueryData) { + return new AppInstallationListQuery(this.project, { + ...this.query, + ...query, + }); + } + + public execute = provideReact(async () => { + const { items, totalCount } = await config.behaviors.appInstallation.list( + this.project.id, + { + limit: config.defaultPaginationLimit, + ...this.query, + }, + ); + + return new AppInstallationList( + this.project, + this.query, + items.map((d) => new AppInstallationListItem(d)), + totalCount, + ); + }, [this.queryId]); + + public getTotalCount = provideReact(async () => { + const { totalCount } = await this.refine({ limit: 1 }).execute(); + return totalCount; + }, [this.queryId]); + + public findOneAndOnly = provideReact(async () => { + const { items, totalCount } = await this.refine({ limit: 2 }).execute(); + if (totalCount === 1) { + return items[0]; + } + }, [this.queryId]); +} + +export class AppInstallationList extends classes( + AppInstallationListQuery, + ListDataModel, +) { + public constructor( + project: Project, + query: AppInstallationListQueryData, + appInstallations: AppInstallationListItem[], + totalCount: number, + ) { + super([project, query], [appInstallations, totalCount]); + } +} diff --git a/packages/models/src/app/AppInstallation/behaviors/api.ts b/packages/models/src/app/AppInstallation/behaviors/api.ts index bbcd9af1..62476402 100644 --- a/packages/models/src/app/AppInstallation/behaviors/api.ts +++ b/packages/models/src/app/AppInstallation/behaviors/api.ts @@ -1,4 +1,8 @@ -import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { + assertStatus, + extractTotalCountHeader, + MittwaldAPIV2Client, +} from "@mittwald/api-client"; import { assertOneOfStatus } from "@mittwald/api-client"; import { AppInstallationBehaviors } from "./types.js"; @@ -22,6 +26,9 @@ export const apiAppInstallationBehaviors = ( projectId, }); assertStatus(response, 200); - return response.data; + return { + items: response.data, + totalCount: extractTotalCountHeader(response), + }; }, }); diff --git a/packages/models/src/app/AppInstallation/behaviors/types.ts b/packages/models/src/app/AppInstallation/behaviors/types.ts index 36792072..70628860 100644 --- a/packages/models/src/app/AppInstallation/behaviors/types.ts +++ b/packages/models/src/app/AppInstallation/behaviors/types.ts @@ -1,13 +1,14 @@ import { AppInstallationListItemData, AppInstallationData, - AppInstallationListQuery, + AppInstallationListQueryData, } from "../types.js"; +import { QueryResponseData } from "../../../base/index.js"; export interface AppInstallationBehaviors { find: (id: string) => Promise; list: ( projectId: string, - query?: AppInstallationListQuery, - ) => Promise; + query?: AppInstallationListQueryData, + ) => Promise>; } diff --git a/packages/models/src/app/AppInstallation/types.ts b/packages/models/src/app/AppInstallation/types.ts index f1267102..ea60f35e 100644 --- a/packages/models/src/app/AppInstallation/types.ts +++ b/packages/models/src/app/AppInstallation/types.ts @@ -1,6 +1,6 @@ import { MittwaldAPIV2 } from "@mittwald/api-client"; -export type AppInstallationListQuery = +export type AppInstallationListQueryData = MittwaldAPIV2.Paths.V2ProjectsProjectIdAppInstallations.Get.Parameters.Query; export type AppInstallationData = diff --git a/packages/models/src/base/ListDataModel.ts b/packages/models/src/base/ListDataModel.ts new file mode 100644 index 00000000..7e80c0b2 --- /dev/null +++ b/packages/models/src/base/ListDataModel.ts @@ -0,0 +1,9 @@ +export class ListDataModel { + public readonly items: readonly TItem[]; + public readonly totalCount: number; + + public constructor(items: TItem[], totalCount: number) { + this.items = Object.freeze(items); + this.totalCount = totalCount; + } +} diff --git a/packages/models/src/base/ListQueryModel.ts b/packages/models/src/base/ListQueryModel.ts new file mode 100644 index 00000000..76c1fbb0 --- /dev/null +++ b/packages/models/src/base/ListQueryModel.ts @@ -0,0 +1,11 @@ +import { hash } from "object-code"; + +export abstract class ListQueryModel { + protected readonly query: TQuery; + public readonly queryId: string; + + public constructor(query: TQuery) { + this.query = query; + this.queryId = hash(query).toString(); + } +} diff --git a/packages/models/src/base/types.ts b/packages/models/src/base/types.ts index a62338b2..7d8988d8 100644 --- a/packages/models/src/base/types.ts +++ b/packages/models/src/base/types.ts @@ -1,3 +1,8 @@ import { DataModel } from "./DataModel.js"; export type DataType = T extends DataModel ? TData : never; + +export type QueryResponseData = { + items: readonly T[]; + totalCount: number; +}; diff --git a/packages/models/src/config/config.ts b/packages/models/src/config/config.ts index fbee52b8..78a96c99 100644 --- a/packages/models/src/config/config.ts +++ b/packages/models/src/config/config.ts @@ -5,6 +5,7 @@ import { IngressBehaviors } from "../domain/Ingress/behaviors/index.js"; import { AppInstallationBehaviors } from "../app/AppInstallation/behaviors/index.js"; interface Config { + defaultPaginationLimit: number; behaviors: { project: ProjectBehaviors; server: ServerBehaviors; @@ -15,6 +16,7 @@ interface Config { } export const config: Config = { + defaultPaginationLimit: 50, behaviors: { project: undefined as unknown as ProjectBehaviors, server: undefined as unknown as ServerBehaviors, diff --git a/packages/models/src/customer/Customer/Customer.ts b/packages/models/src/customer/Customer/Customer.ts index 6d5a65c6..6407260a 100644 --- a/packages/models/src/customer/Customer/Customer.ts +++ b/packages/models/src/customer/Customer/Customer.ts @@ -3,15 +3,31 @@ import { classes } from "polytype"; import { DataModel } from "../../base/DataModel.js"; import assertObjectFound from "../../base/assertObjectFound.js"; import { ReferenceModel } from "../../base/ReferenceModel.js"; -import type { AsyncResourceVariant } from "../../lib/provideReact.js"; -import { provideReact } from "../../lib/provideReact.js"; +import { AsyncResourceVariant, provideReact } from "../../lib/provideReact.js"; import { CustomerListItemData, CustomerData, - CustomerListQuery, + CustomerListQueryData, } from "./types.js"; +import { ListQueryModel } from "../../base/ListQueryModel.js"; +import { ListDataModel } from "../../base/ListDataModel.js"; +import { ServerListQuery } from "../../server/index.js"; +import { ProjectListQuery } from "../../project/index.js"; export class Customer extends ReferenceModel { + public readonly servers: ServerListQuery; + public readonly projects: ProjectListQuery; + + public constructor(id: string) { + super(id); + this.servers = new ServerListQuery({ + customer: this, + }); + this.projects = new ProjectListQuery({ + customer: this, + }); + } + public static ofId(id: string): Customer { return new Customer(id); } @@ -25,13 +41,16 @@ export class Customer extends ReferenceModel { }, ); + public static query(query: CustomerListQueryData = {}) { + return new CustomerListQuery(query); + } + + /** @deprecated Use query() */ public static list = provideReact( async ( - query: CustomerListQuery = {}, - ): Promise>> => { - const data = await config.behaviors.customer.list(query); - return Object.freeze(data.map((d) => new CustomerListItem(d))); - }, + query: CustomerListQueryData = {}, + ): Promise>> => + new CustomerListQuery(query).execute().then((res) => res.items), ); public static get = provideReact( @@ -80,3 +99,54 @@ export class CustomerListItem extends classes( super([data], [data]); } } + +export class CustomerListQuery extends ListQueryModel { + public constructor(query: CustomerListQueryData = {}) { + super(query); + } + + public refine(query: CustomerListQueryData) { + return new CustomerListQuery({ + ...this.query, + ...query, + }); + } + + public execute = provideReact(async () => { + const { items, totalCount } = await config.behaviors.customer.list({ + limit: config.defaultPaginationLimit, + ...this.query, + }); + + return new CustomerList( + this.query, + items.map((d) => new CustomerListItem(d)), + totalCount, + ); + }, [this.queryId]); + + public getTotalCount = provideReact(async () => { + const { totalCount } = await this.refine({ limit: 1 }).execute(); + return totalCount; + }, [this.queryId]); + + public findOneAndOnly = provideReact(async () => { + const { items, totalCount } = await this.refine({ limit: 2 }).execute(); + if (totalCount === 1) { + return items[0]; + } + }, [this.queryId]); +} + +export class CustomerList extends classes( + CustomerListQuery, + ListDataModel, +) { + public constructor( + query: CustomerListQueryData, + customers: CustomerListItem[], + totalCount: number, + ) { + super([query], [customers, totalCount]); + } +} diff --git a/packages/models/src/customer/Customer/behaviors/api.ts b/packages/models/src/customer/Customer/behaviors/api.ts index 34cbcf96..a1bb307d 100644 --- a/packages/models/src/customer/Customer/behaviors/api.ts +++ b/packages/models/src/customer/Customer/behaviors/api.ts @@ -1,4 +1,8 @@ -import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { + assertStatus, + MittwaldAPIV2Client, + extractTotalCountHeader, +} from "@mittwald/api-client"; import { assertOneOfStatus } from "@mittwald/api-client"; import { CustomerBehaviors } from "./types.js"; @@ -21,6 +25,9 @@ export const apiCustomerBehaviors = ( queryParameters: query, }); assertStatus(response, 200); - return response.data; + return { + items: response.data, + totalCount: extractTotalCountHeader(response), + }; }, }); diff --git a/packages/models/src/customer/Customer/behaviors/types.ts b/packages/models/src/customer/Customer/behaviors/types.ts index 6fc82721..c5a26d01 100644 --- a/packages/models/src/customer/Customer/behaviors/types.ts +++ b/packages/models/src/customer/Customer/behaviors/types.ts @@ -1,10 +1,13 @@ import { CustomerListItemData, CustomerData, - CustomerListQuery, + CustomerListQueryData, } from "../types.js"; +import { QueryResponseData } from "../../../base/index.js"; export interface CustomerBehaviors { find: (id: string) => Promise; - list: (query?: CustomerListQuery) => Promise; + list: ( + query?: CustomerListQueryData, + ) => Promise>; } diff --git a/packages/models/src/customer/Customer/types.ts b/packages/models/src/customer/Customer/types.ts index 07c62e60..42e53aae 100644 --- a/packages/models/src/customer/Customer/types.ts +++ b/packages/models/src/customer/Customer/types.ts @@ -1,6 +1,6 @@ import { MittwaldAPIV2 } from "@mittwald/api-client"; -export type CustomerListQuery = +export type CustomerListQueryData = MittwaldAPIV2.Paths.V2Customers.Get.Parameters.Query; export type CustomerData = diff --git a/packages/models/src/domain/Ingress/Ingress.ts b/packages/models/src/domain/Ingress/Ingress.ts index 540564c1..1d989939 100644 --- a/packages/models/src/domain/Ingress/Ingress.ts +++ b/packages/models/src/domain/Ingress/Ingress.ts @@ -5,8 +5,15 @@ import assertObjectFound from "../../base/assertObjectFound.js"; import { ReferenceModel } from "../../base/ReferenceModel.js"; import type { AsyncResourceVariant } from "../../lib/provideReact.js"; import { provideReact } from "../../lib/provideReact.js"; -import { IngressListItemData, IngressData, IngressListQuery } from "./types.js"; +import { + IngressData, + IngressListItemData, + IngressListQueryData, + IngressListQueryModelData, +} from "./types.js"; import { IngressPath } from "../IngressPath/IngressPath.js"; +import { ListQueryModel } from "../../base/ListQueryModel.js"; +import { ListDataModel } from "../../base/ListDataModel.js"; export class Ingress extends ReferenceModel { public static ofId(id: string): Ingress { @@ -17,11 +24,12 @@ export class Ingress extends ReferenceModel { return Ingress.ofId(hostname); } + /** @deprecated: use query() or project.ingresses */ public static list = provideReact( - async (query: IngressListQuery = {}): Promise> => { - const data = await config.behaviors.ingress.list(query); - return data.map((d) => new IngressListItem(d)); - }, + async ( + query: IngressListQueryData = {}, + ): Promise>> => + new IngressListQuery(query).execute().then((r) => r.items), ); public static find = provideReact( @@ -90,3 +98,71 @@ export class IngressListItem extends classes( super([data], [data]); } } + +export class IngressListQuery extends ListQueryModel { + public constructor(query: IngressListQueryModelData = {}) { + super(query); + } + + public refine(query: IngressListQueryModelData = {}) { + return new IngressListQuery({ + ...this.query, + ...query, + }); + } + + public execute = provideReact(async () => { + const { project, ...query } = this.query; + const { items, totalCount } = await config.behaviors.ingress.list({ + /** @todo: use this code when pagination is supported by API */ + // limit: config.defaultPaginationLimit, + ...query, + projectId: project?.id, + }); + + return new IngressList( + this.query, + items.map((d) => new IngressListItem(d)), + totalCount, + ); + }, [this.queryId]); + + public getTotalCount = provideReact(async () => { + /** @todo: use this code when pagination is supported by API */ + // const { totalCount } = await this.refine({ limit: 1 }).execute(); + // return totalCount; + const { items } = await this.refine({}).execute(); + return items.length; + }, [this.queryId]); + + public findOneAndOnly = provideReact(async () => { + /** @todo: use this code when pagination is supported by API */ + // const { items, totalCount } = await this.refine({ limit: 2 }).execute(); + // if (totalCount === 1) { + // return items[0]; + // } + const { items, totalCount } = await this.refine({}).execute(); + if (totalCount === 1) { + return items[0]; + } + }, [this.queryId]); +} + +export class IngressList extends classes( + IngressListQuery, + ListDataModel, +) { + public constructor( + query: IngressListQueryModelData, + ingresses: IngressListItem[], + totalCount: number, + ) { + super([query], [ingresses, totalCount]); + } + + public getDefault() { + const defaultIngress = this.items.find((i) => i.data.isDefault); + assertObjectFound(defaultIngress, IngressListItem, "IngressList"); + return defaultIngress; + } +} diff --git a/packages/models/src/domain/Ingress/behaviors/api.ts b/packages/models/src/domain/Ingress/behaviors/api.ts index 3071a20d..f1f85c9b 100644 --- a/packages/models/src/domain/Ingress/behaviors/api.ts +++ b/packages/models/src/domain/Ingress/behaviors/api.ts @@ -17,13 +17,15 @@ export const apiIngressBehaviors = ( }, list: async (query = {}) => { - const { projectId } = query; const response = await client.domain.ingressListIngresses({ - queryParameters: { - projectId, - }, + queryParameters: query, }); assertStatus(response, 200); - return response.data; + return { + items: response.data, + totalCount: response.data.length, + /** @todo: use this code when pagination is supported by API */ + // totalCount: extractTotalCountHeader(response), + }; }, }); diff --git a/packages/models/src/domain/Ingress/behaviors/types.ts b/packages/models/src/domain/Ingress/behaviors/types.ts index c7dfaadc..0e64eece 100644 --- a/packages/models/src/domain/Ingress/behaviors/types.ts +++ b/packages/models/src/domain/Ingress/behaviors/types.ts @@ -1,10 +1,13 @@ import { IngressListItemData, IngressData, - IngressListQuery, + IngressListQueryData, } from "../types.js"; +import { QueryResponseData } from "../../../base/index.js"; export interface IngressBehaviors { find: (id: string) => Promise; - list: (query?: IngressListQuery) => Promise; + list: ( + query?: IngressListQueryData, + ) => Promise>; } diff --git a/packages/models/src/domain/Ingress/types.ts b/packages/models/src/domain/Ingress/types.ts index adfd4a5c..6295e261 100644 --- a/packages/models/src/domain/Ingress/types.ts +++ b/packages/models/src/domain/Ingress/types.ts @@ -1,8 +1,15 @@ import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { Project } from "../../project/index.js"; -export interface IngressListQuery { - projectId?: string; -} +export type IngressListQueryData = + MittwaldAPIV2.Paths.V2Ingresses.Get.Parameters.Query; + +export type IngressListQueryModelData = Omit< + IngressListQueryData, + "projectId" +> & { + project?: Project; +}; export type IngressData = MittwaldAPIV2.Operations.IngressGetIngress.ResponseData; diff --git a/packages/models/src/project/Project/Project.ts b/packages/models/src/project/Project/Project.ts index f99eee24..97c9b012 100644 --- a/packages/models/src/project/Project/Project.ts +++ b/packages/models/src/project/Project/Project.ts @@ -1,4 +1,9 @@ -import { ProjectListItemData, ProjectData, ProjectListQuery } from "./types.js"; +import { + ProjectData, + ProjectListItemData, + ProjectListQueryData, + ProjectListQueryModelData, +} from "./types.js"; import { config } from "../../config/config.js"; import { classes } from "polytype"; import { DataModel } from "../../base/DataModel.js"; @@ -10,9 +15,27 @@ import { } from "../../lib/provideReact.js"; import { Customer } from "../../customer/Customer/Customer.js"; import { ReferenceModel } from "../../base/ReferenceModel.js"; -import { Ingress, IngressListItem } from "../../domain/index.js"; +import { + Ingress, + IngressListItem, + IngressListQuery, +} from "../../domain/index.js"; +import { ListQueryModel } from "../../base/ListQueryModel.js"; +import { ListDataModel } from "../../base/ListDataModel.js"; +import { AppInstallationListQuery } from "../../app/index.js"; export class Project extends ReferenceModel { + public readonly ingresses: IngressListQuery; + public readonly appInstallations: AppInstallationListQuery; + + public constructor(id: string) { + super(id); + this.ingresses = new IngressListQuery({ + project: this, + }); + this.appInstallations = new AppInstallationListQuery(this); + } + public static ofId(id: string): Project { return new Project(id); } @@ -35,12 +58,16 @@ export class Project extends ReferenceModel { }, ); + public query(query: ProjectListQueryModelData = {}) { + return new ProjectListQuery(query); + } + + /** @deprecated: use query(), Customer.projects or Server.projects */ public static list = provideReact( async ( - query: ProjectListQuery = {}, + query: ProjectListQueryData = {}, ): Promise>> => { - const data = await config.behaviors.project.list(query); - return Object.freeze(data.map((d) => new ProjectListItem(d))); + return new ProjectListQuery(query).execute().then((r) => r.items); }, ); @@ -62,6 +89,7 @@ export class Project extends ReferenceModel { [this.id], ) as AsyncResourceVariant; + /** @deprecated: use ingresses property */ public listIngresses = provideReact(() => Ingress.list({ projectId: this.id }), ) as AsyncResourceVariant; @@ -117,3 +145,56 @@ export class ProjectListItem extends classes( super([data], [data]); } } + +export class ProjectListQuery extends ListQueryModel { + public constructor(query: ProjectListQueryModelData = {}) { + super(query); + } + + public refine(query: ProjectListQueryModelData) { + return new ProjectListQuery({ + ...this.query, + ...query, + }); + } + + public execute = provideReact(async () => { + const { server, customer, ...query } = this.query; + const { items, totalCount } = await config.behaviors.project.list({ + ...query, + serverId: server?.id, + customerId: customer?.id, + }); + + return new ProjectList( + this.query, + items.map((d) => new ProjectListItem(d)), + totalCount, + ); + }, [this.queryId]); + + public getTotalCount = provideReact(async () => { + const { totalCount } = await this.refine({ limit: 1 }).execute(); + return totalCount; + }, [this.queryId]); + + public findOneAndOnly = provideReact(async () => { + const { items, totalCount } = await this.refine({ limit: 2 }).execute(); + if (totalCount === 1) { + return items[0]; + } + }, [this.queryId]); +} + +export class ProjectList extends classes( + ProjectListQuery, + ListDataModel, +) { + public constructor( + query: ProjectListQueryModelData, + projects: ProjectListItem[], + totalCount: number, + ) { + super([query], [projects, totalCount]); + } +} diff --git a/packages/models/src/project/Project/behaviors/api.ts b/packages/models/src/project/Project/behaviors/api.ts index 5836e999..5491a164 100644 --- a/packages/models/src/project/Project/behaviors/api.ts +++ b/packages/models/src/project/Project/behaviors/api.ts @@ -1,5 +1,9 @@ import { ProjectBehaviors } from "./types.js"; -import { assertStatus, MittwaldAPIV2Client } from "@mittwald/api-client"; +import { + assertStatus, + extractTotalCountHeader, + MittwaldAPIV2Client, +} from "@mittwald/api-client"; import { assertOneOfStatus } from "@mittwald/api-client"; export const apiProjectBehaviors = ( @@ -21,7 +25,10 @@ export const apiProjectBehaviors = ( queryParameters: query, }); assertStatus(response, 200); - return response.data; + return { + items: response.data, + totalCount: extractTotalCountHeader(response), + }; }, create: async (serverId: string, description: string) => { diff --git a/packages/models/src/project/Project/behaviors/inMem.ts b/packages/models/src/project/Project/behaviors/inMem.ts index c2878ab5..81b7a55d 100644 --- a/packages/models/src/project/Project/behaviors/inMem.ts +++ b/packages/models/src/project/Project/behaviors/inMem.ts @@ -7,12 +7,17 @@ export const inMemProjectBehaviors = ( find: async (id) => store.get(id), list: async () => { - return Array.from(store.values()).map((detailedProject) => ({ + const items = Array.from(store.values()).map((detailedProject) => ({ ...detailedProject, customerMeta: { id: detailedProject.customerId, }, })); + + return { + items, + totalCount: items.length, + }; }, create: async () => { diff --git a/packages/models/src/project/Project/behaviors/types.ts b/packages/models/src/project/Project/behaviors/types.ts index ee5944a7..cc948633 100644 --- a/packages/models/src/project/Project/behaviors/types.ts +++ b/packages/models/src/project/Project/behaviors/types.ts @@ -1,12 +1,15 @@ import { ProjectListItemData, ProjectData, - ProjectListQuery, + ProjectListQueryData, } from "../types.js"; +import { QueryResponseData } from "../../../base/index.js"; export interface ProjectBehaviors { find: (id: string) => Promise; - list: (query?: ProjectListQuery) => Promise; + list: ( + query?: ProjectListQueryData, + ) => Promise>; create: (serverId: string, description: string) => Promise<{ id: string }>; diff --git a/packages/models/src/project/Project/types.ts b/packages/models/src/project/Project/types.ts index 857101cd..591d69c1 100644 --- a/packages/models/src/project/Project/types.ts +++ b/packages/models/src/project/Project/types.ts @@ -1,8 +1,18 @@ import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { Server } from "../../server/index.js"; +import { Customer } from "../../customer/index.js"; -export type ProjectListQuery = +export type ProjectListQueryData = MittwaldAPIV2.Paths.V2Projects.Get.Parameters.Query; +export type ProjectListQueryModelData = Omit< + ProjectListQueryData, + "serverId" | "customerId" +> & { + server?: Server; + customer?: Customer; +}; + export type ProjectData = MittwaldAPIV2.Operations.ProjectGetProject.ResponseData; diff --git a/packages/models/src/server/Server/Server.ts b/packages/models/src/server/Server/Server.ts index 073cea02..341b5e7e 100644 --- a/packages/models/src/server/Server/Server.ts +++ b/packages/models/src/server/Server/Server.ts @@ -1,14 +1,30 @@ import { ReferenceModel } from "../../base/ReferenceModel.js"; -import { ServerData, ServerListItemData, ServerListQuery } from "./types.js"; +import { + ServerData, + ServerListItemData, + ServerListQueryData, + ServerListQueryModelData, +} from "./types.js"; import { config } from "../../config/config.js"; import { classes } from "polytype"; import { DataModel } from "../../base/DataModel.js"; import assertObjectFound from "../../base/assertObjectFound.js"; -import { Project } from "../../project/index.js"; +import { Project, ProjectListQuery } from "../../project/index.js"; import { FirstParameter, ParamsExceptFirst } from "../../lib/types.js"; import { AsyncResourceVariant, provideReact } from "../../lib/provideReact.js"; +import { ListQueryModel } from "../../base/ListQueryModel.js"; +import { ListDataModel } from "../../base/ListDataModel.js"; export class Server extends ReferenceModel { + public readonly projects: ProjectListQuery; + + public constructor(id: string) { + super(id); + this.projects = new ProjectListQuery({ + server: this, + }); + } + public static ofId(id: string): Server { return new Server(id); } @@ -31,10 +47,16 @@ export class Server extends ReferenceModel { }, ); + public static query(query: ServerListQueryModelData = {}) { + return new ServerListQuery(query); + } + + /** @deprecated: use query() or customer.servers */ public static list = provideReact( - async (query: ServerListQuery = {}): Promise => { - const projectListData = await config.behaviors.server.list(query); - return projectListData.map((d) => new ServerListItem(d)); + async ( + query: ServerListQueryData = {}, + ): Promise> => { + return new ServerListQuery(query).execute().then((r) => r.items); }, ); @@ -44,6 +66,7 @@ export class Server extends ReferenceModel { return Project.create(this.id, ...parameters); } + /** @deprecated Use Server.projects property */ public listProjects = provideReact( async ( query: Omit, "serverId"> = {}, @@ -93,3 +116,56 @@ export class ServerListItem extends classes( super([data], [data]); } } + +export class ServerListQuery extends ListQueryModel { + public constructor(query: ServerListQueryModelData = {}) { + super(query); + } + + public refine(query: ServerListQueryModelData) { + return new ServerListQuery({ + ...this.query, + ...query, + }); + } + + public execute = provideReact(async () => { + const { customer, ...query } = this.query; + const { items, totalCount } = await config.behaviors.server.list({ + limit: config.defaultPaginationLimit, + customerId: customer?.id, + ...query, + }); + + return new ServerList( + this.query, + items.map((d) => new ServerListItem(d)), + totalCount, + ); + }, [this.queryId]); + + public getTotalCount = provideReact(async () => { + const { totalCount } = await this.refine({ limit: 1 }).execute(); + return totalCount; + }, [this.queryId]); + + public findOneAndOnly = provideReact(async () => { + const { items, totalCount } = await this.refine({ limit: 2 }).execute(); + if (totalCount === 1) { + return items[0]; + } + }, [this.queryId]); +} + +export class ServerList extends classes( + ServerListQuery, + ListDataModel, +) { + public constructor( + query: ServerListQueryData, + servers: ServerListItem[], + totalCount: number, + ) { + super([query], [servers, totalCount]); + } +} diff --git a/packages/models/src/server/Server/behaviors/api.ts b/packages/models/src/server/Server/behaviors/api.ts index 36fb3434..528723bc 100644 --- a/packages/models/src/server/Server/behaviors/api.ts +++ b/packages/models/src/server/Server/behaviors/api.ts @@ -2,6 +2,7 @@ import { assertStatus, assertOneOfStatus, MittwaldAPIV2Client, + extractTotalCountHeader, } from "@mittwald/api-client"; import { ServerBehaviors } from "./types.js"; @@ -24,6 +25,9 @@ export const apiServerBehaviors = ( queryParameters: query, }); assertStatus(response, 200); - return response.data; + return { + items: response.data, + totalCount: extractTotalCountHeader(response), + }; }, }); diff --git a/packages/models/src/server/Server/behaviors/types.ts b/packages/models/src/server/Server/behaviors/types.ts index 1280d722..11a7c7cd 100644 --- a/packages/models/src/server/Server/behaviors/types.ts +++ b/packages/models/src/server/Server/behaviors/types.ts @@ -1,6 +1,13 @@ -import { ServerListItemData, ServerData, ServerListQuery } from "../types.js"; +import { + ServerListItemData, + ServerData, + ServerListQueryData, +} from "../types.js"; +import { QueryResponseData } from "../../../base/index.js"; export interface ServerBehaviors { find: (id: string) => Promise; - list: (query?: ServerListQuery) => Promise; + list: ( + query?: ServerListQueryData, + ) => Promise>; } diff --git a/packages/models/src/server/Server/types.ts b/packages/models/src/server/Server/types.ts index daff5d60..dd0a4418 100644 --- a/packages/models/src/server/Server/types.ts +++ b/packages/models/src/server/Server/types.ts @@ -1,8 +1,16 @@ import { MittwaldAPIV2 } from "@mittwald/api-client"; +import { Customer } from "../../customer/index.js"; -export type ServerListQuery = +export type ServerListQueryData = MittwaldAPIV2.Paths.V2Servers.Get.Parameters.Query; +export type ServerListQueryModelData = Omit< + ServerListQueryData, + "customerId" +> & { + customer?: Customer; +}; + export type ServerData = MittwaldAPIV2.Operations.ProjectGetServer.ResponseData; export type ServerListItemData = diff --git a/yarn.lock b/yarn.lock index 8e558e1c..9af817ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1498,6 +1498,7 @@ __metadata: eslint-plugin-json: "npm:^3.1.0" eslint-plugin-prettier: "npm:^5.2.1" jest: "npm:^29.7.0" + object-code: "npm:^1.3.3" polytype: "npm:^0.17.0" prettier: "npm:^3.3.3" react: "npm:^18.3.1"