Skip to content

Commit

Permalink
feat(Models): introduce Query Models and List Models
Browse files Browse the repository at this point in the history
  • Loading branch information
mfal committed Aug 22, 2024
1 parent 4bc0de6 commit f5613f4
Show file tree
Hide file tree
Showing 32 changed files with 637 additions and 75 deletions.
1 change: 1 addition & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions packages/commons/src/types/extractTotalCountHeader.ts
Original file line number Diff line number Diff line change
@@ -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;
};
1 change: 1 addition & 0 deletions packages/commons/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./http.js";
export * from "./simplify.js";
export * from "./assertStatus.js";
export * from "./assertOneOfStatus.js";
export * from "./extractTotalCountHeader.js";
6 changes: 5 additions & 1 deletion packages/mittwald/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
66 changes: 55 additions & 11 deletions packages/models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ModelDetailed>` method.
`function getDetailed(): Promise<ModelDetailed>` and
`function findDetailed(): Promise<ModelDetailed|undefined>` 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
Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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!

Expand Down
1 change: 1 addition & 0 deletions packages/models/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
86 changes: 77 additions & 9 deletions packages/models/src/app/AppInstallation/AppInstallation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<Array<AppInstallationListItem>> => {
const data = await config.behaviors.appInstallation.list(
projectId,
query,
);
return data.map((d) => new AppInstallationListItem(d));
query: AppInstallationListQueryData = {},
): Promise<Readonly<Array<AppInstallationListItem>>> => {
return new AppInstallationListQuery(Project.ofId(projectId), query)
.execute()
.then((r) => r.items);
},
);

Expand Down Expand Up @@ -79,3 +85,65 @@ export class AppInstallationListItem extends classes(
super([data], [data]);
}
}

export class AppInstallationListQuery extends ListQueryModel<AppInstallationListQueryData> {
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<AppInstallationListItem>,
) {
public constructor(
project: Project,
query: AppInstallationListQueryData,
appInstallations: AppInstallationListItem[],
totalCount: number,
) {
super([project, query], [appInstallations, totalCount]);
}
}
11 changes: 9 additions & 2 deletions packages/models/src/app/AppInstallation/behaviors/api.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,6 +26,9 @@ export const apiAppInstallationBehaviors = (
projectId,
});
assertStatus(response, 200);
return response.data;
return {
items: response.data,
totalCount: extractTotalCountHeader(response),
};
},
});
7 changes: 4 additions & 3 deletions packages/models/src/app/AppInstallation/behaviors/types.ts
Original file line number Diff line number Diff line change
@@ -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<AppInstallationData | undefined>;
list: (
projectId: string,
query?: AppInstallationListQuery,
) => Promise<AppInstallationListItemData[]>;
query?: AppInstallationListQueryData,
) => Promise<QueryResponseData<AppInstallationListItemData>>;
}
2 changes: 1 addition & 1 deletion packages/models/src/app/AppInstallation/types.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down
9 changes: 9 additions & 0 deletions packages/models/src/base/ListDataModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class ListDataModel<TItem> {
public readonly items: readonly TItem[];
public readonly totalCount: number;

public constructor(items: TItem[], totalCount: number) {
this.items = Object.freeze(items);
this.totalCount = totalCount;
}
}
11 changes: 11 additions & 0 deletions packages/models/src/base/ListQueryModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { hash } from "object-code";

export abstract class ListQueryModel<TQuery> {
protected readonly query: TQuery;
public readonly queryId: string;

public constructor(query: TQuery) {
this.query = query;
this.queryId = hash(query).toString();
}
}
5 changes: 5 additions & 0 deletions packages/models/src/base/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { DataModel } from "./DataModel.js";

export type DataType<T> = T extends DataModel<infer TData> ? TData : never;

export type QueryResponseData<T> = {
items: readonly T[];
totalCount: number;
};
2 changes: 2 additions & 0 deletions packages/models/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading

0 comments on commit f5613f4

Please sign in to comment.