diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..06a85ca16 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..b49e28696 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,15 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 7 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 3 +# Only issues or pull requests with all of these labels are checked if stale. Defaults to `[]` (disabled) +onlyLabels: + - "more info needed" +# Label to use when marking an issue as stale +staleLabel: "auto closed" +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false \ No newline at end of file diff --git a/examples/basic-rest/e2e/fixtures.ts b/examples/basic-rest/e2e/fixtures.ts new file mode 100644 index 000000000..8c3fefe88 --- /dev/null +++ b/examples/basic-rest/e2e/fixtures.ts @@ -0,0 +1,47 @@ +import { Connection } from 'typeorm' + +import { executeTruncate } from '../../helpers' +import { SubTaskEntity } from '../src/sub-task/sub-task.entity' +import { TagEntity } from '../src/tag/tag.entity' +import { TodoItemEntity } from '../src/todo-item/todo-item.entity' + +const tables = ['todo_item', 'sub_task', 'tag'] +export const truncate = async (connection: Connection): Promise => executeTruncate(connection, tables) + +export const refresh = async (connection: Connection): Promise => { + await truncate(connection) + + const todoRepo = connection.getRepository(TodoItemEntity) + const subTaskRepo = connection.getRepository(SubTaskEntity) + const tagsRepo = connection.getRepository(TagEntity) + + const urgentTag = await tagsRepo.save({ name: 'Urgent' }) + const homeTag = await tagsRepo.save({ name: 'Home' }) + const workTag = await tagsRepo.save({ name: 'Work' }) + const questionTag = await tagsRepo.save({ name: 'Question' }) + const blockedTag = await tagsRepo.save({ name: 'Blocked' }) + + const todoItems = await todoRepo.save([ + { title: 'Create Nest App', completed: true, tags: [urgentTag, homeTag] }, + { title: 'Create Entity', completed: false, tags: [urgentTag, workTag] }, + { title: 'Create Entity Service', completed: false, tags: [blockedTag, workTag] }, + { title: 'Add Todo Item Resolver', completed: false, tags: [blockedTag, homeTag] }, + { + title: 'How to create item With Sub Tasks', + completed: false, + tags: [questionTag, blockedTag] + } + ]) + + await subTaskRepo.save( + todoItems.reduce( + (subTasks, todo) => [ + ...subTasks, + { completed: true, title: `${todo.title} - Sub Task 1`, todoItem: todo }, + { completed: false, title: `${todo.title} - Sub Task 2`, todoItem: todo }, + { completed: false, title: `${todo.title} - Sub Task 3`, todoItem: todo } + ], + [] as Partial[] + ) + ) +} diff --git a/examples/basic-rest/e2e/tag.endpoint.spec.ts b/examples/basic-rest/e2e/tag.endpoint.spec.ts new file mode 100644 index 000000000..cd1b385d2 --- /dev/null +++ b/examples/basic-rest/e2e/tag.endpoint.spec.ts @@ -0,0 +1,59 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { OffsetConnectionType } from '@ptc-org/nestjs-query-rest' +import request from 'supertest' +import { Connection } from 'typeorm' + +import { generateOpenapiSpec } from '../../helpers/generate-openapi-spec' +import { AppModule } from '../src/app.module' +import { TagDTO } from '../src/tag/dto/tag.dto' +import { refresh } from './fixtures' + +describe('TagResolver (basic rest - e2e)', () => { + let app: INestApplication + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule] + }).compile() + + app = moduleRef.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidUnknownValues: false + }) + ) + + generateOpenapiSpec(app, __dirname) + + await app.init() + await refresh(app.get(Connection)) + }) + + afterAll(() => refresh(app.get(Connection))) + + const tags = [ + { id: 1, name: 'Urgent' }, + { id: 2, name: 'Home' }, + { id: 3, name: 'Work' }, + { id: 4, name: 'Question' }, + { id: 5, name: 'Blocked' } + ] + + describe('query', () => { + it(`should return a connection`, () => + request(app.getHttpServer()) + .get('/tag-dtos') + .expect(200) + .then(({ body }) => { + const { nodes }: OffsetConnectionType = body + expect(nodes).toHaveLength(5) + expect(nodes.map((e) => e)).toMatchObject(tags) + })) + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/examples/basic-rest/open-api.json b/examples/basic-rest/open-api.json new file mode 100644 index 000000000..a90b1accf --- /dev/null +++ b/examples/basic-rest/open-api.json @@ -0,0 +1,769 @@ +{ + "openapi": "3.0.0", + "paths": { + "/sub-task-dtos/{id}": { + "get": { + "operationId": "subTaskDTOS.findById", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskDTO" + } + } + } + } + } + }, + "delete": { + "operationId": "subTaskDTOS.deleteOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskDTO" + } + } + } + } + } + }, + "put": { + "operationId": "subTaskDTOS.updateOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskUpdateDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskDTO" + } + } + } + } + } + } + }, + "/sub-task-dtos": { + "get": { + "operationId": "subTaskDTOS.queryMany", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskDTOConnection" + } + } + } + } + } + }, + "post": { + "operationId": "subTaskDTOS.createOne", + "summary": "", + "tags": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSubTaskDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubTaskDTO" + } + } + } + } + } + } + }, + "/todo-item-dtos/{id}": { + "get": { + "operationId": "todoItemDTOS.findById", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemDTO" + } + } + } + } + } + }, + "delete": { + "operationId": "todoItemDTOS.deleteOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemDTO" + } + } + } + } + } + }, + "put": { + "operationId": "todoItemDTOS.updateOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemUpdateDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemDTO" + } + } + } + } + } + } + }, + "/todo-item-dtos": { + "get": { + "operationId": "todoItemDTOS.queryMany", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemDTOConnection" + } + } + } + } + } + }, + "post": { + "operationId": "todoItemDTOS.createOne", + "summary": "", + "tags": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTodoItemDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodoItemDTO" + } + } + } + } + } + } + }, + "/tag-dtos/{id}": { + "get": { + "operationId": "tagDTOS.findById", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDTO" + } + } + } + } + } + }, + "delete": { + "operationId": "tagDTOS.deleteOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDTO" + } + } + } + } + } + }, + "put": { + "operationId": "tagDTOS.updateOne", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDTO" + } + } + } + } + } + } + }, + "/tag-dtos": { + "get": { + "operationId": "tagDTOS.queryMany", + "summary": "", + "tags": [], + "parameters": [ + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDTOConnection" + } + } + } + } + } + }, + "post": { + "operationId": "tagDTOS.createOne", + "summary": "", + "tags": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTagDTO" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagDTO" + } + } + } + } + } + } + } + }, + "info": { + "title": "", + "description": "", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "SubTaskDTO": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "completed": { + "type": "boolean" + }, + "created": { + "format": "date-time", + "type": "string" + }, + "updated": { + "format": "date-time", + "type": "string" + }, + "todoItemId": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "completed", + "created", + "updated", + "todoItemId" + ] + }, + "PageInfoType": { + "type": "object", + "properties": { + "hasNextPage": { + "type": "boolean", + "description": "true if paging forward and there are more records.", + "nullable": true + }, + "hasPreviousPage": { + "type": "boolean", + "description": "true if paging backwards and there are more records.", + "nullable": true + } + } + }, + "SubTaskDTOConnection": { + "type": "object", + "properties": { + "pageInfo": { + "description": "Paging information", + "allOf": [ + { + "$ref": "#/components/schemas/PageInfoType" + } + ] + }, + "totalCount": { + "type": "number", + "description": "Total amount of records." + }, + "nodes": { + "description": "Array of nodes.", + "type": "array", + "items": { + "$ref": "#/components/schemas/SubTaskDTO" + } + } + }, + "required": [ + "pageInfo", + "totalCount", + "nodes" + ] + }, + "SubTaskUpdateDTO": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "completed": { + "type": "boolean", + "nullable": true + }, + "todoItemId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "title" + ] + }, + "CreateSubTaskDTO": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "completed": { + "type": "boolean" + }, + "todoItemId": { + "type": "string" + } + }, + "required": [ + "title", + "completed", + "todoItemId" + ] + }, + "TodoItemDTO": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "isCompleted": { + "type": "boolean" + } + }, + "required": [ + "id", + "title", + "isCompleted" + ] + }, + "TodoItemDTOConnection": { + "type": "object", + "properties": { + "pageInfo": { + "description": "Paging information", + "allOf": [ + { + "$ref": "#/components/schemas/PageInfoType" + } + ] + }, + "totalCount": { + "type": "number", + "description": "Total amount of records." + }, + "nodes": { + "description": "Array of nodes.", + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoItemDTO" + } + } + }, + "required": [ + "pageInfo", + "totalCount", + "nodes" + ] + }, + "TodoItemUpdateDTO": { + "type": "object", + "properties": { + "title": { + "type": "string", + "nullable": true + }, + "completed": { + "type": "boolean", + "nullable": true + } + } + }, + "CreateTodoItemDTO": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + } + }, + "required": [ + "title", + "completed" + ] + }, + "TagDTO": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "created": { + "format": "date-time", + "type": "string" + }, + "updated": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "name", + "created", + "updated" + ] + }, + "TagDTOConnection": { + "type": "object", + "properties": { + "pageInfo": { + "description": "Paging information", + "allOf": [ + { + "$ref": "#/components/schemas/PageInfoType" + } + ] + }, + "totalCount": { + "type": "number", + "description": "Total amount of records." + }, + "nodes": { + "description": "Array of nodes.", + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDTO" + } + } + }, + "required": [ + "pageInfo", + "totalCount", + "nodes" + ] + }, + "CreateTagDTO": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + } +} \ No newline at end of file diff --git a/examples/basic-rest/src/app.module.ts b/examples/basic-rest/src/app.module.ts new file mode 100644 index 000000000..e2d6d7b77 --- /dev/null +++ b/examples/basic-rest/src/app.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { typeormOrmConfig } from '../../helpers' +import { SubTaskModule } from './sub-task/sub-task.module' +import { TagModule } from './tag/tag.module' +import { TodoItemModule } from './todo-item/todo-item.module' + +@Module({ + imports: [TypeOrmModule.forRoot(typeormOrmConfig('basic')), SubTaskModule, TodoItemModule, TagModule] +}) +export class AppModule {} diff --git a/examples/basic-rest/src/sub-task/dto/sub-task.dto.ts b/examples/basic-rest/src/sub-task/dto/sub-task.dto.ts new file mode 100644 index 000000000..f3f7df5e5 --- /dev/null +++ b/examples/basic-rest/src/sub-task/dto/sub-task.dto.ts @@ -0,0 +1,24 @@ +import { FilterableField } from '@ptc-org/nestjs-query-rest' + +export class SubTaskDTO { + @FilterableField() + id!: number + + @FilterableField() + title!: string + + @FilterableField({ nullable: true }) + description?: string + + @FilterableField() + completed!: boolean + + @FilterableField() + created!: Date + + @FilterableField() + updated!: Date + + @FilterableField() + todoItemId!: string +} diff --git a/examples/basic-rest/src/sub-task/dto/subtask-input.dto.ts b/examples/basic-rest/src/sub-task/dto/subtask-input.dto.ts new file mode 100644 index 000000000..f8202f59d --- /dev/null +++ b/examples/basic-rest/src/sub-task/dto/subtask-input.dto.ts @@ -0,0 +1,23 @@ +import { Field } from '@ptc-org/nestjs-query-rest' +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator' + +export class CreateSubTaskDTO { + @Field() + @IsString() + @IsNotEmpty() + title!: string + + @Field({ nullable: true }) + @IsOptional() + @IsString() + @IsNotEmpty() + description?: string + + @Field() + @IsBoolean() + completed!: boolean + + @Field() + @IsNotEmpty() + todoItemId!: string +} diff --git a/examples/basic-rest/src/sub-task/dto/subtask-update.dto.ts b/examples/basic-rest/src/sub-task/dto/subtask-update.dto.ts new file mode 100644 index 000000000..b23e146f6 --- /dev/null +++ b/examples/basic-rest/src/sub-task/dto/subtask-update.dto.ts @@ -0,0 +1,26 @@ +import { Field } from '@ptc-org/nestjs-query-rest' +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator' + +export class SubTaskUpdateDTO { + @Field() + @IsOptional() + @IsNotEmpty() + @IsString() + title?: string + + @Field({ nullable: true }) + @IsOptional() + @IsNotEmpty() + @IsString() + description?: string + + @Field({ nullable: true }) + @IsOptional() + @IsBoolean() + completed?: boolean + + @Field({ nullable: true }) + @IsOptional() + @IsNotEmpty() + todoItemId?: string +} diff --git a/examples/basic-rest/src/sub-task/sub-task.entity.ts b/examples/basic-rest/src/sub-task/sub-task.entity.ts new file mode 100644 index 000000000..6d5cc62da --- /dev/null +++ b/examples/basic-rest/src/sub-task/sub-task.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + ObjectType, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { TodoItemEntity } from '../todo-item/todo-item.entity' + +@Entity({ name: 'sub_task' }) +export class SubTaskEntity { + @PrimaryGeneratedColumn() + id!: number + + @Column() + title!: string + + @Column({ nullable: true }) + description?: string + + @Column() + completed!: boolean + + @Column({ nullable: false, name: 'todo_item_id' }) + todoItemId!: string + + @ManyToOne((): ObjectType => TodoItemEntity, (td) => td.subTasks, { + onDelete: 'CASCADE', + nullable: false + }) + @JoinColumn({ name: 'todo_item_id' }) + todoItem!: TodoItemEntity + + @CreateDateColumn() + created!: Date + + @UpdateDateColumn() + updated!: Date +} diff --git a/examples/basic-rest/src/sub-task/sub-task.module.ts b/examples/basic-rest/src/sub-task/sub-task.module.ts new file mode 100644 index 000000000..536f9a46a --- /dev/null +++ b/examples/basic-rest/src/sub-task/sub-task.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { NestjsQueryRestModule } from '@ptc-org/nestjs-query-rest' +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm' + +import { SubTaskDTO } from './dto/sub-task.dto' +import { CreateSubTaskDTO } from './dto/subtask-input.dto' +import { SubTaskUpdateDTO } from './dto/subtask-update.dto' +import { SubTaskEntity } from './sub-task.entity' + +@Module({ + imports: [ + NestjsQueryRestModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([SubTaskEntity])], + endpoints: [ + { + DTOClass: SubTaskDTO, + EntityClass: SubTaskEntity, + CreateDTOClass: CreateSubTaskDTO, + UpdateDTOClass: SubTaskUpdateDTO + } + ] + }) + ] +}) +export class SubTaskModule {} diff --git a/examples/basic-rest/src/tag/dto/tag-input.dto.ts b/examples/basic-rest/src/tag/dto/tag-input.dto.ts new file mode 100644 index 000000000..4006fa80a --- /dev/null +++ b/examples/basic-rest/src/tag/dto/tag-input.dto.ts @@ -0,0 +1,9 @@ +import { Field } from '@ptc-org/nestjs-query-rest' +import { IsNotEmpty, IsString } from 'class-validator' + +export class TagInputDTO { + @Field() + @IsString() + @IsNotEmpty() + name!: string +} diff --git a/examples/basic-rest/src/tag/dto/tag.dto.ts b/examples/basic-rest/src/tag/dto/tag.dto.ts new file mode 100644 index 000000000..25560c870 --- /dev/null +++ b/examples/basic-rest/src/tag/dto/tag.dto.ts @@ -0,0 +1,15 @@ +import { FilterableField } from '@ptc-org/nestjs-query-rest' + +export class TagDTO { + @FilterableField() + id!: number + + @FilterableField() + name!: string + + @FilterableField() + created!: Date + + @FilterableField() + updated!: Date +} diff --git a/examples/basic-rest/src/tag/tag.entity.ts b/examples/basic-rest/src/tag/tag.entity.ts new file mode 100644 index 000000000..3938b8cb5 --- /dev/null +++ b/examples/basic-rest/src/tag/tag.entity.ts @@ -0,0 +1,21 @@ +import { Column, CreateDateColumn, Entity, ManyToMany, ObjectType, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm' + +import { TodoItemEntity } from '../todo-item/todo-item.entity' + +@Entity({ name: 'tag' }) +export class TagEntity { + @PrimaryGeneratedColumn() + id!: number + + @Column() + name!: string + + @CreateDateColumn() + created!: Date + + @UpdateDateColumn() + updated!: Date + + @ManyToMany((): ObjectType => TodoItemEntity, (td) => td.tags) + todoItems!: TodoItemEntity[] +} diff --git a/examples/basic-rest/src/tag/tag.module.ts b/examples/basic-rest/src/tag/tag.module.ts new file mode 100644 index 000000000..5faf351e3 --- /dev/null +++ b/examples/basic-rest/src/tag/tag.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { NestjsQueryRestModule } from '@ptc-org/nestjs-query-rest' +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm' + +import { TagDTO } from './dto/tag.dto' +import { TagInputDTO } from './dto/tag-input.dto' +import { TagEntity } from './tag.entity' + +@Module({ + imports: [ + NestjsQueryRestModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TagEntity])], + endpoints: [ + { + DTOClass: TagDTO, + EntityClass: TagEntity, + CreateDTOClass: TagInputDTO, + UpdateDTOClass: TagInputDTO + } + ] + }) + ] +}) +export class TagModule {} diff --git a/examples/basic-rest/src/todo-item/dto/todo-item-input.dto.ts b/examples/basic-rest/src/todo-item/dto/todo-item-input.dto.ts new file mode 100644 index 000000000..f30f42b5f --- /dev/null +++ b/examples/basic-rest/src/todo-item/dto/todo-item-input.dto.ts @@ -0,0 +1,13 @@ +import { Field } from '@ptc-org/nestjs-query-rest' +import { IsBoolean, IsString, MaxLength } from 'class-validator' + +export class TodoItemInputDTO { + @IsString() + @MaxLength(20) + @Field() + title!: string + + @IsBoolean() + @Field() + completed!: boolean +} diff --git a/examples/basic-rest/src/todo-item/dto/todo-item-update.dto.ts b/examples/basic-rest/src/todo-item/dto/todo-item-update.dto.ts new file mode 100644 index 000000000..c31a6258a --- /dev/null +++ b/examples/basic-rest/src/todo-item/dto/todo-item-update.dto.ts @@ -0,0 +1,15 @@ +import { Field } from '@ptc-org/nestjs-query-rest' +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator' + +export class TodoItemUpdateDTO { + @IsOptional() + @IsString() + @MaxLength(20) + @Field({ nullable: true }) + title?: string + + @IsOptional() + @IsBoolean() + @Field({ nullable: true }) + completed?: boolean +} diff --git a/examples/basic-rest/src/todo-item/dto/todo-item.dto.ts b/examples/basic-rest/src/todo-item/dto/todo-item.dto.ts new file mode 100644 index 000000000..13a5175da --- /dev/null +++ b/examples/basic-rest/src/todo-item/dto/todo-item.dto.ts @@ -0,0 +1,23 @@ +import { FilterableField } from '@ptc-org/nestjs-query-rest' + +export class TodoItemDTO { + @FilterableField() + id!: number + + @FilterableField() + title!: string + + @FilterableField({ nullable: true }) + description?: string + + @FilterableField({ + name: 'isCompleted' + }) + completed!: boolean + + @FilterableField({ filterOnly: true }) + created!: Date + + @FilterableField({ filterOnly: true }) + updated!: Date +} diff --git a/examples/basic-rest/src/todo-item/todo-item.entity.ts b/examples/basic-rest/src/todo-item/todo-item.entity.ts new file mode 100644 index 000000000..c018f7643 --- /dev/null +++ b/examples/basic-rest/src/todo-item/todo-item.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { SubTaskEntity } from '../sub-task/sub-task.entity' +import { TagEntity } from '../tag/tag.entity' + +@Entity({ name: 'todo_item' }) +export class TodoItemEntity { + @PrimaryGeneratedColumn() + id!: number + + @Column() + title!: string + + @Column({ nullable: true }) + description?: string + + @Column() + completed!: boolean + + @OneToMany(() => SubTaskEntity, (subTask) => subTask.todoItem) + subTasks!: SubTaskEntity[] + + @CreateDateColumn() + created!: Date + + @UpdateDateColumn() + updated!: Date + + @ManyToMany(() => TagEntity, (tag) => tag.todoItems) + @JoinTable() + tags!: TagEntity[] +} diff --git a/examples/basic-rest/src/todo-item/todo-item.module.ts b/examples/basic-rest/src/todo-item/todo-item.module.ts new file mode 100644 index 000000000..3895ee4ff --- /dev/null +++ b/examples/basic-rest/src/todo-item/todo-item.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { NestjsQueryRestModule } from '@ptc-org/nestjs-query-rest' +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm' + +import { TodoItemDTO } from './dto/todo-item.dto' +import { TodoItemInputDTO } from './dto/todo-item-input.dto' +import { TodoItemUpdateDTO } from './dto/todo-item-update.dto' +import { TodoItemEntity } from './todo-item.entity' + +@Module({ + imports: [ + NestjsQueryRestModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + endpoints: [ + { + DTOClass: TodoItemDTO, + EntityClass: TodoItemEntity, + CreateDTOClass: TodoItemInputDTO, + UpdateDTOClass: TodoItemUpdateDTO + } + ] + }) + ] +}) +export class TodoItemModule {} diff --git a/examples/helpers/generate-openapi-spec.ts b/examples/helpers/generate-openapi-spec.ts new file mode 100644 index 000000000..ce35ec16d --- /dev/null +++ b/examples/helpers/generate-openapi-spec.ts @@ -0,0 +1,19 @@ +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import type { INestApplication } from '@nestjs/common' + +export function generateOpenapiSpec(app: INestApplication, dirname: string) { + // Generate the document in development + const config = new DocumentBuilder().build() + + const document = SwaggerModule.createDocument(app, config) + + const openApiLocation = resolve(dirname, '../open-api.json') + if (existsSync(openApiLocation)) { + unlinkSync(openApiLocation) + } + + writeFileSync(openApiLocation, JSON.stringify(document, null, 2)) +} diff --git a/examples/project.json b/examples/project.json index 50c6394a4..4f1811287 100644 --- a/examples/project.json +++ b/examples/project.json @@ -10,7 +10,9 @@ }, "e2e": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/examples"], + "outputs": [ + "{workspaceRoot}/coverage/examples" + ], "options": { "jestConfig": "jest.e2e.ts", "runInBand": true, @@ -19,5 +21,13 @@ } }, "tags": [], - "implicitDependencies": ["core", "query-graphql", "query-mongoose", "query-sequelize", "query-typegoose", "query-typeorm"] + "implicitDependencies": [ + "core", + "query-graphql", + "query-mongoose", + "query-sequelize", + "query-typegoose", + "query-typeorm", + "query-rest" + ] } diff --git a/jest.preset.js b/jest.preset.js index 27385b49a..ff813b023 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -19,7 +19,8 @@ module.exports = { '@ptc-org/nestjs-query-typeorm': process.cwd() + '/packages/query-typeorm/src', '@ptc-org/nestjs-query-sequelize': process.cwd() + '/packages/query-sequelize/src', '@ptc-org/nestjs-query-typegoose': process.cwd() + '/packages/query-typegoose/src', - '@ptc-org/nestjs-query-mongoose': process.cwd() + '/packages/query-mongoose/src' + '@ptc-org/nestjs-query-mongoose': process.cwd() + '/packages/query-mongoose/src', + '@ptc-org/nestjs-query-rest': process.cwd() + '/packages/query-rest/src' }, testEnvironment: 'node', setupFilesAfterEnv: ['jest-extended'], diff --git a/package.json b/package.json index 8a0db1b5e..27014a6d7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.4.1", "@nestjs/sequelize": "10.0.1", + "@nestjs/swagger": "^7.1.15", "@nestjs/typeorm": "^10.0.2", "class-validator": "0.14.1", "mongoose": "^8.5.3", diff --git a/packages/query-rest/.babelrc b/packages/query-rest/.babelrc new file mode 100644 index 000000000..e24a5465f --- /dev/null +++ b/packages/query-rest/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nrwl/web/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/packages/query-rest/.eslintrc.json b/packages/query-rest/.eslintrc.json new file mode 100644 index 000000000..10e609fcf --- /dev/null +++ b/packages/query-rest/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": [ + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "parserOptions": { + "project": "./tsconfig.json" + }, + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} diff --git a/packages/query-rest/README.md b/packages/query-rest/README.md new file mode 100644 index 000000000..83e5cbbe1 --- /dev/null +++ b/packages/query-rest/README.md @@ -0,0 +1,23 @@ +

+ Nestjs-query Logo +

+ +[![npm version](https://img.shields.io/npm/v/@ptc-org/nestjs-query-rest.svg)](https://www.npmjs.org/package/@ptc-org/nestjs-query-rest) +[![Test](https://github.com/tripss/nestjs-query/workflows/Test/badge.svg?branch=master)](https://github.com/tripss/nestjs-query/actions?query=workflow%3ATest+and+branch%3Amaster+) +[![Coverage Status](https://codecov.io/gh/TriPSs/nestjs-query/branch/master/graph/badge.svg?token=29EX71ID2P)](https://codecov.io/gh/TriPSs/nestjs-query) +[![Known Vulnerabilities](https://snyk.io/test/github/tripss/nestjs-query/badge.svg?targetFile=packages/query-rest/package.json)](https://snyk.io/test/github/tripss/nestjs-query?targetFile=packages/query-rest/package.json) + +# `@ptc-org/nestjs-query-rest` + +This package provides a code first implementation of rest CRUD endpoints. It is built on top of +of [nestjs](https://nestjs.com/). + +## Installation + +[Install Guide](https://tripss.github.io/nestjs-query/docs/introduction/install) + +## Getting Started + +The get started with the `@ptc-org/nestjs-query-rest` package checkout +the [Getting Started](https://tripss.github.io/nestjs-query/docs/rest/getting-started) docs. + diff --git a/packages/query-rest/jest.config.ts b/packages/query-rest/jest.config.ts new file mode 100644 index 000000000..1b27dbfb9 --- /dev/null +++ b/packages/query-rest/jest.config.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +// eslint-disable-next-line import/no-default-export +export default { + displayName: 'query-rest', + preset: '../../jest.preset.js', + globals: {}, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json' + } + ] + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/packages/query-rest' +} diff --git a/packages/query-rest/package.json b/packages/query-rest/package.json new file mode 100644 index 000000000..053b7250d --- /dev/null +++ b/packages/query-rest/package.json @@ -0,0 +1,49 @@ +{ + "name": "@ptc-org/nestjs-query-rest", + "version": "4.3.0", + "description": "Nestjs rest query adapter", + "author": "doug-martin ", + "homepage": "https://github.com/tripss/nestjs-query#readme", + "keywords": [ + "rest", + "crud", + "nestjs" + ], + "license": "MIT", + "main": "src/index.js", + "types": "src/index.d.ts", + "directories": { + "lib": "src", + "test": "__tests__" + }, + "files": [ + "src/**" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tripss/nestjs-query.git", + "directory": "packages/query-rest" + }, + "bugs": { + "url": "https://github.com/tripss/nestjs-query/issues" + }, + "dependencies": { + "lodash.omit": "^4.5.0", + "lower-case-first": "^2.0.2", + "pluralize": "^8.0.0", + "tslib": "^2.6.2", + "upper-case-first": "^2.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/graphql": "^11.0.0 || ^12.0.0", + "@nestjs/swagger": "^7.0.0", + "class-transformer": "^0.5", + "class-validator": "^0.14.0", + "ts-morph": "^19.0.0" + } +} diff --git a/packages/query-rest/project.json b/packages/query-rest/project.json new file mode 100644 index 000000000..b29c34864 --- /dev/null +++ b/packages/query-rest/project.json @@ -0,0 +1,48 @@ +{ + "name": "query-rest", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/query-rest/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/query-rest/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/packages/query-rest"], + "options": { + "jestConfig": "packages/query-rest/jest.config.ts", + "passWithNoTests": true + } + }, + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/query-rest", + "tsConfig": "packages/query-rest/tsconfig.lib.json", + "packageJson": "packages/query-rest/package.json", + "main": "packages/query-rest/src/index.ts", + "assets": ["packages/query-rest/*.md"], + "updateBuildableProjectDepsInPackageJson": true, + "buildableProjectDepsInPackageJsonType": "dependencies" + } + }, + "version": { + "executor": "@jscutlery/semver:version", + "options": {} + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "npm publish ./dist/packages/query-rest --access public" + } + } + }, + "tags": [], + "implicitDependencies": ["core"] +} diff --git a/packages/query-rest/src/auth/authorizer.ts b/packages/query-rest/src/auth/authorizer.ts new file mode 100644 index 000000000..06f57dcc7 --- /dev/null +++ b/packages/query-rest/src/auth/authorizer.ts @@ -0,0 +1,47 @@ +import { Filter } from '@ptc-org/nestjs-query-core' + +export enum OperationGroup { + READ = 'read', + AGGREGATE = 'aggregate', + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete' +} + +export interface AuthorizationContext { + /** The name of the method that uses the @AuthorizeFilter decorator */ + readonly operationName: string + + /** The group this operation belongs to */ + readonly operationGroup: OperationGroup + + /** If the operation does not modify any entities */ + readonly readonly: boolean + + /** If the operation can affect multiple entities */ + readonly many: boolean +} + +export interface CustomAuthorizer { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any + authorize(context: any, authorizerContext: AuthorizationContext): Promise> + + authorizeRelation?( + relationName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + authorizerContext: AuthorizationContext + ): Promise | undefined> +} + +export interface Authorizer extends CustomAuthorizer { + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any + authorize(context: any, authorizerContext: AuthorizationContext): Promise> + + authorizeRelation( + relationName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + authorizerContext: AuthorizationContext + ): Promise | undefined> +} diff --git a/packages/query-rest/src/auth/default-crud.authorizer.ts b/packages/query-rest/src/auth/default-crud.authorizer.ts new file mode 100644 index 000000000..2392ce2c5 --- /dev/null +++ b/packages/query-rest/src/auth/default-crud.authorizer.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable, Optional } from '@nestjs/common' +import { ModuleRef } from '@nestjs/core' +import { Class, Filter } from '@ptc-org/nestjs-query-core' + +// import { getAuthorizer } from '../decorators' +// import { ResolverRelation } from '../resolvers/relations' +import { AuthorizationContext, Authorizer, CustomAuthorizer } from './authorizer' +import { getCustomAuthorizerToken } from './tokens' + +export interface AuthorizerOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authorize: (context: any, authorizationContext: AuthorizationContext) => Filter | Promise> +} + +// const createRelationAuthorizer = (opts: AuthorizerOptions): Authorizer => ({ +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// async authorize(context: any, authorizationContext: AuthorizationContext): Promise> { +// return opts.authorize(context, authorizationContext) ?? {} +// }, +// authorizeRelation(): Promise> { +// return Promise.reject(new Error('Not implemented')) +// } +// }) + +export function createDefaultAuthorizer( + DTOClass: Class, + opts?: CustomAuthorizer | AuthorizerOptions // instance of class or authorizer options +): Class> { + @Injectable() + class DefaultAuthorizer implements Authorizer { + readonly authOptions?: AuthorizerOptions | CustomAuthorizer = opts + + readonly relationsAuthorizers: Map | undefined> + + // private readonly relations: Map> + + constructor( + private readonly moduleRef: ModuleRef, + @Optional() @Inject(getCustomAuthorizerToken(DTOClass)) private readonly customAuthorizer?: CustomAuthorizer + ) { + this.relationsAuthorizers = new Map | undefined>() + // this.relations = this.getRelations() + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async authorize(context: any, authorizationContext: AuthorizationContext): Promise> { + return ( + this.customAuthorizer?.authorize(context, authorizationContext) ?? + this.authOptions?.authorize(context, authorizationContext) ?? + {} + ) + } + + public async authorizeRelation( + relationName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any, + authorizationContext: AuthorizationContext + ): Promise> { + if (this.customAuthorizer && typeof this.customAuthorizer.authorizeRelation === 'function') { + const filterFromCustomAuthorizer = await this.customAuthorizer.authorizeRelation( + relationName, + context, + authorizationContext + ) + + if (filterFromCustomAuthorizer) { + return filterFromCustomAuthorizer + } + } + return {} + // this.addRelationAuthorizerIfNotExist(relationName) + // return this.relationsAuthorizers.get(relationName)?.authorize(context, authorizationContext) ?? {} + } + + private addRelationAuthorizerIfNotExist(relationName: string) { + if (!this.relationsAuthorizers.has(relationName)) { + return + // const relation = this.relations.get(relationName) + // if (!relation) return + // if (relation.auth) { + // this.relationsAuthorizers.set(relationName, createRelationAuthorizer(relation.auth)) + // } else if (getAuthorizer(relation.DTO)) { + // this.relationsAuthorizers.set(relationName, this.moduleRef.get(getAuthorizerToken(relation.DTO), { strict: false })) + // } + } + } + + // private getRelations(): Map> { + // const { many = {}, one = {} } = {}// getRelations(DTOClass) + // const relationsMap = new Map>() + // Object.keys(many).forEach((relation) => relationsMap.set(relation, many[relation])) + // Object.keys(one).forEach((relation) => relationsMap.set(relation, one[relation])) + // return relationsMap + // } + } + + return DefaultAuthorizer +} diff --git a/packages/query-rest/src/auth/index.ts b/packages/query-rest/src/auth/index.ts new file mode 100644 index 000000000..0081c7a62 --- /dev/null +++ b/packages/query-rest/src/auth/index.ts @@ -0,0 +1,3 @@ +export * from './authorizer' +export * from './default-crud.authorizer' +export * from './tokens' diff --git a/packages/query-rest/src/auth/tokens.ts b/packages/query-rest/src/auth/tokens.ts new file mode 100644 index 000000000..7617f969b --- /dev/null +++ b/packages/query-rest/src/auth/tokens.ts @@ -0,0 +1,4 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +export const getAuthorizerToken = (DTOClass: Class): string => `${DTOClass.name}Authorizer` +export const getCustomAuthorizerToken = (DTOClass: Class): string => `${DTOClass.name}CustomAuthorizer` diff --git a/packages/query-rest/src/common/dto.utils.ts b/packages/query-rest/src/common/dto.utils.ts new file mode 100644 index 000000000..8f4133892 --- /dev/null +++ b/packages/query-rest/src/common/dto.utils.ts @@ -0,0 +1,35 @@ +import { Class } from '@ptc-org/nestjs-query-core' +import { lowerCaseFirst } from 'lower-case-first' +import { plural } from 'pluralize' +import { upperCaseFirst } from 'upper-case-first' + +export interface DTONamesOpts { + dtoName?: string +} + +/** @internal */ +export interface DTONames { + baseName: string + baseNameLower: string + pluralBaseName: string + pluralBaseNameLower: string + endpointName: string +} + +const kebabize = (str: string) => str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase()) + +/** @internal */ +export const getDTONames = (DTOClass: Class, opts?: DTONamesOpts): DTONames => { + const baseName = upperCaseFirst(opts?.dtoName ?? DTOClass.name) + const pluralBaseName = plural(baseName) + const baseNameLower = lowerCaseFirst(baseName) + const pluralBaseNameLower = plural(baseNameLower) + + return { + baseName, + baseNameLower, + pluralBaseName, + pluralBaseNameLower, + endpointName: kebabize(pluralBaseName) + } +} diff --git a/packages/query-rest/src/common/index.ts b/packages/query-rest/src/common/index.ts new file mode 100644 index 000000000..c61c52166 --- /dev/null +++ b/packages/query-rest/src/common/index.ts @@ -0,0 +1,3 @@ +export * from './dto.utils' +export * from './object.utils' +export * from './resolver.utils' diff --git a/packages/query-rest/src/common/object.utils.ts b/packages/query-rest/src/common/object.utils.ts new file mode 100644 index 000000000..7cadf891b --- /dev/null +++ b/packages/query-rest/src/common/object.utils.ts @@ -0,0 +1,9 @@ +export const removeUndefinedValues = (obj: T): T => { + const keys = Object.keys(obj) as (keyof T)[] + return keys.reduce((cleansed: T, key) => { + if (obj[key] === undefined) { + return cleansed + } + return { ...cleansed, [key]: obj[key] } + }, {} as T) +} diff --git a/packages/query-rest/src/common/resolver.utils.ts b/packages/query-rest/src/common/resolver.utils.ts new file mode 100644 index 000000000..71e850201 --- /dev/null +++ b/packages/query-rest/src/common/resolver.utils.ts @@ -0,0 +1,19 @@ +import { BaseResolverOptions } from '../decorators' + +const mergeArrays = (arr1?: T[], arr2?: T[]): T[] | undefined => { + if (arr1 || arr2) { + return [...(arr1 ?? []), ...(arr2 ?? [])] + } + return undefined +} + +export const mergeBaseResolverOpts = (into: Into, from: BaseResolverOptions): Into => { + const guards = mergeArrays(from.guards, into.guards) + const interceptors = mergeArrays(from.interceptors, into.interceptors) + const pipes = mergeArrays(from.pipes, into.pipes) + const filters = mergeArrays(from.filters, into.filters) + const decorators = mergeArrays(from.decorators, into.decorators) + const tags = mergeArrays(from.tags, into.tags) + + return { ...into, tags, guards, interceptors, pipes, filters, decorators } +} diff --git a/packages/query-rest/src/connection/index.ts b/packages/query-rest/src/connection/index.ts new file mode 100644 index 000000000..d92fd9c9d --- /dev/null +++ b/packages/query-rest/src/connection/index.ts @@ -0,0 +1 @@ +export { ArrayConnectionType, ConnectionType, OffsetConnectionType, OffsetPageInfoType } from './interfaces' diff --git a/packages/query-rest/src/connection/interfaces.ts b/packages/query-rest/src/connection/interfaces.ts new file mode 100644 index 000000000..bf585ee09 --- /dev/null +++ b/packages/query-rest/src/connection/interfaces.ts @@ -0,0 +1,58 @@ +import { Class, Filter, Query } from '@ptc-org/nestjs-query-core' + +import { PagingStrategies } from '../types' + +interface BaseConnectionOptions { + enableTotalCount?: boolean + connectionName?: string +} + +export interface OffsetConnectionOptions extends BaseConnectionOptions { + pagingStrategy?: PagingStrategies.OFFSET +} + +export interface ArrayConnectionOptions extends BaseConnectionOptions { + pagingStrategy?: PagingStrategies.NONE +} + +export type ConnectionOptions = OffsetConnectionOptions | ArrayConnectionOptions + +export interface OffsetPageInfoType { + hasNextPage: boolean + hasPreviousPage: boolean +} + +export type OffsetConnectionType = { + pageInfo: OffsetPageInfoType + totalCount?: number + nodes: DTO[] +} + +export type ArrayConnectionType = DTO[] + +export type ConnectionType = OffsetConnectionType | ArrayConnectionType + +export type QueryMany> = (query: Q) => Promise +export type Count = (filter: Filter) => Promise + +export type PagerResult = { + totalCount?: number +} + +export interface Pager { + page>(queryMany: QueryMany, query: Q, count?: Count): Promise +} + +export type InferConnectionTypeFromStrategy = S extends PagingStrategies.NONE + ? ArrayConnectionType + : S extends PagingStrategies.OFFSET + ? OffsetConnectionType + : never + +export interface StaticConnectionType extends Class> { + createFromPromise>( + queryMany: QueryMany, + query: Q, + count?: Count + ): Promise> +} diff --git a/packages/query-rest/src/connection/offset/offset-connection.type.ts b/packages/query-rest/src/connection/offset/offset-connection.type.ts new file mode 100644 index 000000000..0d030c813 --- /dev/null +++ b/packages/query-rest/src/connection/offset/offset-connection.type.ts @@ -0,0 +1,59 @@ +import { Class, MapReflector, Query } from '@ptc-org/nestjs-query-core' +import { plainToInstance } from 'class-transformer' + +import { Field } from '../../decorators' +import { OffsetQueryArgsTypeOpts, PagingStrategies } from '../../types' +import { Count, OffsetConnectionType, OffsetPageInfoType, QueryMany, StaticConnectionType } from '../interfaces' +import { getOrCreateOffsetPageInfoType } from './offset-page-info.type' +import { OffsetPager } from './pager' + +const reflector = new MapReflector('nestjs-query:offset-connection-type') + +export function getOrCreateOffsetConnectionType( + TItemClass: Class, + opts: OffsetQueryArgsTypeOpts +): StaticConnectionType { + const connectionName = opts?.connectionName || `${TItemClass.name}OffsetConnection` + + return reflector.memoize(TItemClass, connectionName, () => { + const PIT = getOrCreateOffsetPageInfoType() + const pager = new OffsetPager() + + class OffsetConnection implements OffsetConnectionType { + public static async createFromPromise>( + queryMany: QueryMany, + query: Query, + count?: Count + ): Promise { + const { pageInfo, nodes, totalCount } = await pager.page(queryMany, query, count) + + return new OffsetConnection(new PIT(pageInfo.hasNextPage, pageInfo.hasPreviousPage), nodes, totalCount) + } + + constructor(pageInfo?: OffsetPageInfoType, nodes?: DTO[], totalCount?: number) { + this.pageInfo = pageInfo ?? { hasNextPage: false, hasPreviousPage: false } + this.nodes = plainToInstance(TItemClass, nodes ?? [], { excludeExtraneousValues: true }) + this.totalCount = totalCount + } + + @Field(() => PIT, { + description: 'Paging information' + }) + public pageInfo!: OffsetPageInfoType + + @Field({ + description: 'Total amount of records.' + }) + public totalCount?: number + + @Field(() => [TItemClass], { + description: 'Array of nodes.' + }) + public nodes!: DTO[] + } + + Object.defineProperty(OffsetConnection, 'name', { value: connectionName, writable: false }) + + return OffsetConnection + }) +} diff --git a/packages/query-rest/src/connection/offset/offset-page-info.type.ts b/packages/query-rest/src/connection/offset/offset-page-info.type.ts new file mode 100644 index 000000000..23241c325 --- /dev/null +++ b/packages/query-rest/src/connection/offset/offset-page-info.type.ts @@ -0,0 +1,39 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +import { Field } from '../../decorators' +import { OffsetPageInfoType } from '../interfaces' + +export interface OffsetPageInfoTypeConstructor { + new (hasNextPage: boolean, hasPreviousPage: boolean): OffsetPageInfoType +} + +/** @internal */ +let pageInfoType: Class | null = null +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const getOrCreateOffsetPageInfoType = (): OffsetPageInfoTypeConstructor => { + if (pageInfoType) { + return pageInfoType + } + + class PageInfoType implements OffsetPageInfoType { + constructor(hasNextPage: boolean, hasPreviousPage: boolean) { + this.hasNextPage = hasNextPage + this.hasPreviousPage = hasPreviousPage + } + + @Field({ + description: 'true if paging forward and there are more records.', + nullable: true + }) + hasNextPage: boolean + + @Field({ + description: 'true if paging backwards and there are more records.', + nullable: true + }) + hasPreviousPage: boolean + } + + pageInfoType = PageInfoType + return pageInfoType +} diff --git a/packages/query-rest/src/connection/offset/pager/index.ts b/packages/query-rest/src/connection/offset/pager/index.ts new file mode 100644 index 000000000..aa7e1409d --- /dev/null +++ b/packages/query-rest/src/connection/offset/pager/index.ts @@ -0,0 +1,2 @@ +export { OffsetPagerResult } from './interfaces' +export { OffsetPager } from './pager' diff --git a/packages/query-rest/src/connection/offset/pager/interfaces.ts b/packages/query-rest/src/connection/offset/pager/interfaces.ts new file mode 100644 index 000000000..6aef51884 --- /dev/null +++ b/packages/query-rest/src/connection/offset/pager/interfaces.ts @@ -0,0 +1,17 @@ +import { Paging, Query } from '@ptc-org/nestjs-query-core' + +import { OffsetConnectionType, PagerResult } from '../../interfaces' + +export type OffsetPagingOpts = Required + +export interface OffsetPagingMeta { + opts: OffsetPagingOpts + query: Query +} + +export interface QueryResults { + nodes: DTO[] + hasExtraNode: boolean +} + +export type OffsetPagerResult = PagerResult & Omit, 'totalCount'> diff --git a/packages/query-rest/src/connection/offset/pager/pager.ts b/packages/query-rest/src/connection/offset/pager/pager.ts new file mode 100644 index 000000000..9a8ed328d --- /dev/null +++ b/packages/query-rest/src/connection/offset/pager/pager.ts @@ -0,0 +1,83 @@ +import { Query } from '@ptc-org/nestjs-query-core' + +import { Count, Pager, QueryMany } from '../../interfaces' +import { OffsetPagerResult, OffsetPagingMeta, OffsetPagingOpts, QueryResults } from './interfaces' + +const EMPTY_PAGING_RESULTS = (): OffsetPagerResult => ({ + nodes: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + totalCount: undefined +}) + +const DEFAULT_PAGING_META = (query: Query): OffsetPagingMeta => ({ + opts: { offset: 0, limit: 0 }, + query +}) + +export class OffsetPager implements Pager> { + public async page>( + queryMany: QueryMany, + query: Q, + count?: Count + ): Promise> { + const pagingMeta = this.getPageMeta(query) + if (!this.isValidPaging(pagingMeta)) { + return EMPTY_PAGING_RESULTS() + } + const results = await this.runQuery(queryMany, query, pagingMeta) + return this.createPagingResult(results, pagingMeta, await count?.(query.filter)) + } + + private isValidPaging(pagingMeta: OffsetPagingMeta): boolean { + return pagingMeta.opts.limit > 0 + } + + private async runQuery>( + queryMany: QueryMany, + query: Q, + pagingMeta: OffsetPagingMeta + ): Promise> { + const windowedQuery = this.createQuery(query, pagingMeta) + const nodes = await queryMany(windowedQuery) + const returnNodes = this.checkForExtraNode(nodes, pagingMeta.opts) + const hasExtraNode = returnNodes.length !== nodes.length + return { nodes: returnNodes, hasExtraNode } + } + + private getPageMeta(query: Query): OffsetPagingMeta { + const { limit = 25, offset = 0 } = query.paging ?? {} + if (!limit) { + return DEFAULT_PAGING_META(query) + } + return { opts: { limit, offset }, query } + } + + private createPagingResult( + results: QueryResults, + pagingMeta: OffsetPagingMeta, + totalCount?: number + ): OffsetPagerResult { + const { nodes, hasExtraNode } = results + const pageInfo = { + hasNextPage: hasExtraNode, + // we have a previous page if we are going backwards and have an extra node. + hasPreviousPage: pagingMeta.opts.offset > 0 + } + + return { nodes, pageInfo, totalCount } + } + + private createQuery>(query: Q, pagingMeta: OffsetPagingMeta): Q { + const { limit, offset } = pagingMeta.opts + return { ...query, paging: { limit: limit + 1, offset } } + } + + private checkForExtraNode(nodes: DTO[], opts: OffsetPagingOpts): DTO[] { + const returnNodes = [...nodes] + const hasExtraNode = nodes.length > opts.limit + if (hasExtraNode) { + returnNodes.pop() + } + return returnNodes + } +} diff --git a/packages/query-rest/src/decorators/api-schema.decorator.ts b/packages/query-rest/src/decorators/api-schema.decorator.ts new file mode 100644 index 000000000..098e72acb --- /dev/null +++ b/packages/query-rest/src/decorators/api-schema.decorator.ts @@ -0,0 +1,21 @@ +import type { Class } from '@ptc-org/nestjs-query-core' + +interface ApiSchemaOptions { + name?: string +} + +export function ApiSchema(options?: ApiSchemaOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (constructor: Class) => { + const wrapper = class extends constructor {} + + if (options?.name) { + Object.defineProperty(wrapper, 'name', { + value: options.name, + writable: false + }) + } + + return wrapper + } +} diff --git a/packages/query-rest/src/decorators/authorize-filter.decorator.ts b/packages/query-rest/src/decorators/authorize-filter.decorator.ts new file mode 100644 index 000000000..1bceeec4e --- /dev/null +++ b/packages/query-rest/src/decorators/authorize-filter.decorator.ts @@ -0,0 +1,91 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common' + +import { AuthorizationContext, OperationGroup } from '../auth' +import { AuthorizerContext } from '../interceptors' + +type PartialAuthorizationContext = Partial & Pick + +function getContext(executionContext: ExecutionContext): C { + return executionContext.switchToHttp().getRequest() +} + +function getAuthorizerFilter>(context: C, authorizationContext: AuthorizationContext) { + if (!context.authorizer) { + return undefined + } + return context.authorizer.authorize(context, authorizationContext) +} + +// function getRelationAuthFilter>( +// context: C, +// relationName: string, +// authorizationContext: AuthorizationContext +// ) { +// if (!context.authorizer) { +// return undefined +// } +// return context.authorizer.authorizeRelation(relationName, context, authorizationContext) +// } + +function getAuthorizationContext( + methodName: string | symbol, + partialAuthContext?: PartialAuthorizationContext +): AuthorizationContext { + if (!partialAuthContext) + return new Proxy({} as AuthorizationContext, { + get: () => { + throw new Error( + `No AuthorizationContext available for method ${methodName.toString()}! Make sure that you provide an AuthorizationContext to your custom methods as argument of the @AuthorizerFilter decorator.` + ) + } + }) + + return { + operationName: methodName.toString(), + readonly: + partialAuthContext.operationGroup === OperationGroup.READ || partialAuthContext.operationGroup === OperationGroup.AGGREGATE, + ...partialAuthContext + } +} + +export function AuthorizerFilter(partialAuthContext?: PartialAuthorizationContext): ParameterDecorator { + // eslint-disable-next-line @typescript-eslint/ban-types + return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { + const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext) + return createParamDecorator((data: unknown, executionContext: ExecutionContext) => + getAuthorizerFilter(getContext>(executionContext), authorizationContext) + )()(target, propertyKey, parameterIndex) + } +} + +// export function RelationAuthorizerFilter( +// relationName: string, +// partialAuthContext?: PartialAuthorizationContext +// ): ParameterDecorator { +// // eslint-disable-next-line @typescript-eslint/ban-types +// return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { +// const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext) +// return createParamDecorator((data: unknown, executionContext: ExecutionContext) => +// getRelationAuthFilter(getContext>(executionContext), relationName, authorizationContext) +// )()(target, propertyKey, parameterIndex) +// } +// } +// +// export function ModifyRelationAuthorizerFilter( +// relationName: string, +// partialAuthContext?: PartialAuthorizationContext +// ): ParameterDecorator { +// // eslint-disable-next-line @typescript-eslint/ban-types +// return (target: Object, propertyKey: string | symbol, parameterIndex: number) => { +// const authorizationContext = getAuthorizationContext(propertyKey, partialAuthContext) +// return createParamDecorator( +// async (data: unknown, executionContext: ExecutionContext): Promise> => { +// const context = getContext>(executionContext) +// return { +// filter: await getAuthorizerFilter(context, authorizationContext), +// relationFilter: await getRelationAuthFilter(context, relationName, authorizationContext) +// } +// } +// )()(target, propertyKey, parameterIndex) +// } +// } diff --git a/packages/query-rest/src/decorators/authorizer.decorator.ts b/packages/query-rest/src/decorators/authorizer.decorator.ts new file mode 100644 index 000000000..c7653f72a --- /dev/null +++ b/packages/query-rest/src/decorators/authorizer.decorator.ts @@ -0,0 +1,24 @@ +import { Class, MetaValue, ValueReflector } from '@ptc-org/nestjs-query-core' + +import { Authorizer, AuthorizerOptions, createDefaultAuthorizer, CustomAuthorizer } from '../auth' +import { AUTHORIZER_KEY, CUSTOM_AUTHORIZER_KEY } from './constants' + +const reflector = new ValueReflector(AUTHORIZER_KEY) +const customAuthorizerReflector = new ValueReflector(CUSTOM_AUTHORIZER_KEY) + +export function Authorize( + optsOrAuthorizerOrClass: Class> | CustomAuthorizer | AuthorizerOptions +) { + return (DTOClass: Class): void => { + if (!('authorize' in optsOrAuthorizerOrClass)) { + // If the user provided a class, provide the custom authorizer and create a default authorizer that injects the custom authorizer + customAuthorizerReflector.set(DTOClass, optsOrAuthorizerOrClass) + return reflector.set(DTOClass, createDefaultAuthorizer(DTOClass)) + } + return reflector.set(DTOClass, createDefaultAuthorizer(DTOClass, optsOrAuthorizerOrClass)) + } +} + +export const getAuthorizer = (DTOClass: Class): MetaValue>> => reflector.get(DTOClass) +export const getCustomAuthorizer = (DTOClass: Class): MetaValue>> => + customAuthorizerReflector.get(DTOClass) diff --git a/packages/query-rest/src/decorators/constants.ts b/packages/query-rest/src/decorators/constants.ts new file mode 100644 index 000000000..4cf88eed8 --- /dev/null +++ b/packages/query-rest/src/decorators/constants.ts @@ -0,0 +1,11 @@ +export const FILTERABLE_FIELD_KEY = 'nestjs-query:filterable-field' +export const ID_FIELD_KEY = 'nestjs-query:id-field' +export const RELATION_KEY = 'nestjs-query:relation' +export const REFERENCE_KEY = 'nestjs-query:reference' + +export const AUTHORIZER_KEY = 'nestjs-query:authorizer' +export const CUSTOM_AUTHORIZER_KEY = 'nestjs-query:custom-authorizer' + +export const KEY_SET_KEY = 'nestjs-query:key-set' + +export const QUERY_OPTIONS_KEY = 'nestjs-query:query-options' diff --git a/packages/query-rest/src/decorators/controller-methods.decorator.ts b/packages/query-rest/src/decorators/controller-methods.decorator.ts new file mode 100644 index 000000000..381ce83e1 --- /dev/null +++ b/packages/query-rest/src/decorators/controller-methods.decorator.ts @@ -0,0 +1,176 @@ +import { + applyDecorators, + ClassSerializerInterceptor, + Delete as NestDelete, + Get as NestGet, + Post as NestPost, + Put as NestPut, + SerializeOptions, + UseInterceptors +} from '@nestjs/common' +import { ApiBody, ApiBodyOptions, ApiOperation, ApiOperationOptions, ApiParam, ApiResponse } from '@nestjs/swagger' +import { isArray } from 'class-validator' + +import { ReturnTypeFunc } from '../interfaces/return-type-func' +import { isDisabled, ResolverMethod, ResolverMethodOpts } from './resolver-method.decorator' + +interface MethodDecoratorArg extends ResolverMethodOpts { + path?: string | string[] + operation?: ApiOperationOptions +} + +interface MutationMethodDecoratorArg extends MethodDecoratorArg { + body?: ApiBodyOptions +} + +const methodDecorator = (method: (path?: string | string[]) => MethodDecorator) => { + return ( + returnTypeFuncOrOptions?: ReturnTypeFunc | MethodDecoratorArg | MutationMethodDecoratorArg, + maybeOptions: MethodDecoratorArg | MutationMethodDecoratorArg = {}, + ...resolverOpts: (MethodDecoratorArg | MutationMethodDecoratorArg)[] + ): MethodDecorator | PropertyDecorator => { + let returnTypeFunc: ReturnTypeFunc | undefined + let options = maybeOptions + + if (typeof returnTypeFuncOrOptions === 'object') { + options = returnTypeFuncOrOptions + returnTypeFuncOrOptions = null + } else { + returnTypeFunc = returnTypeFuncOrOptions + } + + if (isDisabled([options, ...resolverOpts])) { + return (): void => {} + } + + if (!options.path) { + options.path = [] + } + + const paths: string[] = options.path && !isArray(options.path) ? ([options.path] as string[]) : (options.path as string[]) + + const decorators = [method(paths), ResolverMethod(options, ...resolverOpts)] + // Add all params to the swagger definition + .concat( + paths.reduce( + (params, path) => + params.concat( + path + .split('/') + .filter((partialPath) => partialPath.startsWith(':')) + .map((param) => param.replace(':', '')) + .filter((param) => param !== 'id') + .map((param) => + ApiParam({ + name: param, + type: 'string', + required: true + }) + ) + ), + [] as MethodDecorator[] + ) + ) + + if (returnTypeFunc) { + const returnedType = returnTypeFunc() + const returnTypeIsArray = Array.isArray(returnedType) + const type = returnTypeIsArray ? returnedType[0] : returnedType + + decorators.push( + ApiResponse({ + status: 200, + type, + isArray: returnTypeIsArray + }) + ) + + decorators.push( + SerializeOptions({ + type, + excludeExtraneousValues: true + }), + UseInterceptors(ClassSerializerInterceptor) + ) + } + + if (options.operation) { + decorators.push(ApiOperation(options.operation)) + } + + if ((options as MutationMethodDecoratorArg).body) { + decorators.push(ApiBody((options as MutationMethodDecoratorArg).body)) + } + + return applyDecorators(...decorators) + } +} + +export function Get(options: MethodDecoratorArg, ...resolverOpts: ResolverMethodOpts[]): PropertyDecorator & MethodDecorator +export function Get( + returnTypeFunction?: ReturnTypeFunc, + options?: MethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator + +export function Get( + returnTypeFuncOrOptions?: ReturnTypeFunc | MethodDecoratorArg, + maybeOptions?: MethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): MethodDecorator | PropertyDecorator { + return methodDecorator(NestGet)(returnTypeFuncOrOptions, maybeOptions, ...resolverOpts) +} + +export function Post( + options: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator +export function Post( + returnTypeFunction?: ReturnTypeFunc, + options?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator + +export function Post( + returnTypeFuncOrOptions?: ReturnTypeFunc | MutationMethodDecoratorArg, + maybeOptions?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): MethodDecorator | PropertyDecorator { + return methodDecorator(NestPost)(returnTypeFuncOrOptions, maybeOptions, ...resolverOpts) +} + +export function Put( + options: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator +export function Put( + returnTypeFunction?: ReturnTypeFunc, + options?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator + +export function Put( + returnTypeFuncOrOptions?: ReturnTypeFunc | MutationMethodDecoratorArg, + maybeOptions?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): MethodDecorator | PropertyDecorator { + return methodDecorator(NestPut)(returnTypeFuncOrOptions, maybeOptions, ...resolverOpts) +} + +export function Delete( + options: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator +export function Delete( + returnTypeFunction?: ReturnTypeFunc, + options?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): PropertyDecorator & MethodDecorator + +export function Delete( + returnTypeFuncOrOptions?: ReturnTypeFunc | MutationMethodDecoratorArg, + maybeOptions?: MutationMethodDecoratorArg, + ...resolverOpts: ResolverMethodOpts[] +): MethodDecorator | PropertyDecorator { + return methodDecorator(NestDelete)(returnTypeFuncOrOptions, maybeOptions, ...resolverOpts) +} diff --git a/packages/query-rest/src/decorators/decorator.utils.ts b/packages/query-rest/src/decorators/decorator.utils.ts new file mode 100644 index 000000000..60695c844 --- /dev/null +++ b/packages/query-rest/src/decorators/decorator.utils.ts @@ -0,0 +1,22 @@ +export type ComposableDecorator = MethodDecorator | PropertyDecorator | ClassDecorator | ParameterDecorator +export type ComposedDecorator = MethodDecorator & PropertyDecorator & ClassDecorator & ParameterDecorator + +export function composeDecorators(...decorators: ComposableDecorator[]): ComposedDecorator { + // eslint-disable-next-line @typescript-eslint/ban-types + return ( + // eslint-disable-next-line @typescript-eslint/ban-types + target: TFunction | object, + propertyKey?: string | symbol, + descriptorOrIndex?: TypedPropertyDescriptor | number + ) => { + decorators.forEach((decorator) => { + if (target instanceof Function && !descriptorOrIndex) { + return (decorator as ClassDecorator)(target) + } + if (typeof descriptorOrIndex === 'number') { + return (decorator as ParameterDecorator)(target, propertyKey, descriptorOrIndex) + } + return (decorator as MethodDecorator | PropertyDecorator)(target, propertyKey, descriptorOrIndex) + }) + } +} diff --git a/packages/query-rest/src/decorators/field.decorator.ts b/packages/query-rest/src/decorators/field.decorator.ts new file mode 100644 index 000000000..68458b8f0 --- /dev/null +++ b/packages/query-rest/src/decorators/field.decorator.ts @@ -0,0 +1,160 @@ +import { applyDecorators } from '@nestjs/common' +import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger' +import { Expose, Transform, Type } from 'class-transformer' +import { + ArrayMaxSize, + IsDate, + IsEnum, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateNested +} from 'class-validator' + +import { ReturnTypeFunc } from '../interfaces/return-type-func' + +export type FieldOptions = ApiPropertyOptions & { + // prevents the IsEnum decorator from being added + skipIsEnum?: boolean + forceArray?: boolean +} + +/** + * Decorator for Fields that should be filterable through a [[FilterType]] + * + * @example + * + * In the following DTO `id`, `title` and `completed` are filterable. + * + * ```ts + * import { FilterableField } from '@ptc-org/nestjs-query-graphql'; + * import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; + * + * @ObjectType('TodoItem') + * export class TodoItemDTO { + * @FilterableField(() => ID) + * id!: string; + * + * @FilterableField() + * title!: string; + * + * @FilterableField() + * completed!: boolean; + * + * @Field(() => GraphQLISODateTime) + * created!: Date; + * + * @Field(() => GraphQLISODateTime) + * updated!: Date; + * } + * ``` + */ +export function Field(): PropertyDecorator & MethodDecorator +export function Field(options: FieldOptions): PropertyDecorator & MethodDecorator +export function Field(returnTypeFunction?: ReturnTypeFunc, options?: FieldOptions): PropertyDecorator & MethodDecorator +export function Field( + returnTypeFuncOrOptions?: ReturnTypeFunc | FieldOptions, + maybeOptions?: FieldOptions +): MethodDecorator | PropertyDecorator { + let returnTypeFunc: ReturnTypeFunc | undefined + let advancedOptions: FieldOptions | undefined + if (typeof returnTypeFuncOrOptions === 'function') { + returnTypeFunc = returnTypeFuncOrOptions + advancedOptions = maybeOptions + } else if (typeof returnTypeFuncOrOptions === 'object') { + advancedOptions = returnTypeFuncOrOptions + } else if (typeof maybeOptions === 'object') { + advancedOptions = maybeOptions + } + + return (target: object, propertyKey: string, descriptor: TypedPropertyDescriptor) => { + const returnedType = !returnTypeFunc + ? (target?.constructor?.[METADATA_FACTORY_NAME]?.()[propertyKey]?.type ?? + Reflect.getMetadata('design:type', target, propertyKey)) + : returnTypeFunc() + + const isArray = returnedType && Array.isArray(returnedType) + const type = isArray ? returnedType[0] : returnedType + + const options = { + required: !advancedOptions?.nullable && advancedOptions?.default === undefined, + example: advancedOptions?.default, + ...advancedOptions + } + + // Remove non-valid options + delete options.forceArray + delete options.skipIsEnum + + const decorators = [ + Expose({ name: advancedOptions?.name }), + ApiProperty({ + type, + isArray, + ...options + }) + ] + + if (isArray && options.maxItems !== undefined) { + decorators.push(ArrayMaxSize(options.maxItems)) + } + + if (isArray && advancedOptions?.forceArray) { + decorators.push(Transform(({ value }) => (Array.isArray(value) ? value : [value]))) + } + + if (options.minLength) { + decorators.push(MinLength(options.minLength)) + } + + if (options.maxLength) { + decorators.push(MaxLength(options.maxLength)) + } + + if (options.minimum !== undefined) { + decorators.push(Min(options.minimum)) + } + + if (options.maximum !== undefined) { + decorators.push(Max(options.maximum)) + } + + if (options.required) { + decorators.push(IsNotEmpty()) + } else { + decorators.push(IsOptional()) + } + + if (type) { + decorators.push(Type(() => type as never)) + + if (type === String) { + decorators.push(IsString()) + } else if (type === Number) { + decorators.push(IsNumber()) + } else if (type === Date) { + decorators.push(IsDate()) + } + + if (returnTypeFunc && typeof type === 'function') { + decorators.push(ValidateNested()) + + if (!isArray) { + decorators.push(IsObject()) + } + } + } + + if (options.enum && !advancedOptions?.skipIsEnum) { + decorators.push(IsEnum(options.enum)) + } + + return applyDecorators(...decorators)(target, propertyKey, descriptor) + } +} diff --git a/packages/query-rest/src/decorators/filterable-field.decorator.ts b/packages/query-rest/src/decorators/filterable-field.decorator.ts new file mode 100644 index 000000000..2b75d2760 --- /dev/null +++ b/packages/query-rest/src/decorators/filterable-field.decorator.ts @@ -0,0 +1,112 @@ +import { applyDecorators } from '@nestjs/common' +import { ApiPropertyOptions } from '@nestjs/swagger' +import { ArrayReflector, Class, getPrototypeChain } from '@ptc-org/nestjs-query-core' + +import { ReturnTypeFunc, ReturnTypeFuncValue } from '../interfaces/return-type-func' +import { FILTERABLE_FIELD_KEY } from './constants' +import { Field, FieldOptions } from './field.decorator' + +const reflector = new ArrayReflector(FILTERABLE_FIELD_KEY) +export type FilterableFieldOptions = { + allowedComparisons?: ['=', '!='] + filterRequired?: boolean + filterOnly?: boolean + filterDecorators?: PropertyDecorator[] +} & ApiPropertyOptions + +export interface FilterableFieldDescriptor { + propertyName: string + schemaName: string + target: Class + returnTypeFunc?: ReturnTypeFunc + advancedOptions?: FilterableFieldOptions +} + +export function filterableFieldOptionsToField(advancedOptions: FilterableFieldOptions): FieldOptions { + // Remove fields that are not needed in the Field decorator + const { filterRequired, filterDecorators, filterOnly, ...fieldOptions } = advancedOptions + + return fieldOptions +} + +/** + * Decorator for Fields that should be filterable through a [[FilterType]] + * + * @example + * + * In the following DTO `id`, `title` and `completed` are filterable. + * + * ```ts + * import { FilterableField } from '@ptc-org/nestjs-query-graphql'; + * import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; + * + * @ObjectType('TodoItem') + * export class TodoItemDTO { + * @FilterableField(() => ID) + * id!: string; + * + * @FilterableField() + * title!: string; + * + * @FilterableField() + * completed!: boolean; + * + * @Field(() => GraphQLISODateTime) + * created!: Date; + * + * @Field(() => GraphQLISODateTime) + * updated!: Date; + * } + * ``` + */ +export function FilterableField(): PropertyDecorator & MethodDecorator +export function FilterableField(options: FilterableFieldOptions): PropertyDecorator & MethodDecorator +export function FilterableField( + returnTypeFunction?: ReturnTypeFunc, + options?: FilterableFieldOptions +): PropertyDecorator & MethodDecorator +export function FilterableField( + returnTypeFuncOrOptions?: ReturnTypeFunc | FilterableFieldOptions, + maybeOptions?: FilterableFieldOptions +): MethodDecorator | PropertyDecorator { + let returnTypeFunc: ReturnTypeFunc | undefined + let advancedOptions: FilterableFieldOptions | undefined + if (typeof returnTypeFuncOrOptions === 'function') { + returnTypeFunc = returnTypeFuncOrOptions + advancedOptions = maybeOptions + } else if (typeof returnTypeFuncOrOptions === 'object') { + advancedOptions = returnTypeFuncOrOptions + } else if (typeof maybeOptions === 'object') { + advancedOptions = maybeOptions + } + return ( + // eslint-disable-next-line @typescript-eslint/ban-types + target: Object, + propertyName: string | symbol, + descriptor: TypedPropertyDescriptor + ): TypedPropertyDescriptor | void => { + const Ctx = Reflect.getMetadata('design:type', target, propertyName) as Class + reflector.append(target.constructor as Class, { + propertyName: propertyName.toString(), + schemaName: propertyName.toString(), + target: Ctx, + returnTypeFunc, + advancedOptions + }) + + if (advancedOptions?.filterOnly) { + return undefined + } + + applyDecorators(Field(() => returnTypeFunc, filterableFieldOptionsToField(advancedOptions)))(target, propertyName, descriptor) + } +} + +export function getFilterableFields(DTOClass: Class): FilterableFieldDescriptor[] { + return getPrototypeChain(DTOClass).reduce((fields, Cls) => { + const existingFieldNames = fields.map((t) => t.propertyName) + const typeFields = reflector.get(Cls) ?? [] + const newFields = typeFields.filter((t) => !existingFieldNames.includes(t.propertyName)) + return [...newFields, ...fields] + }, [] as FilterableFieldDescriptor[]) +} diff --git a/packages/query-rest/src/decorators/hook-args.decorator.ts b/packages/query-rest/src/decorators/hook-args.decorator.ts new file mode 100644 index 000000000..aa46de021 --- /dev/null +++ b/packages/query-rest/src/decorators/hook-args.decorator.ts @@ -0,0 +1,69 @@ +import { ArgumentMetadata, Body as NestBody, Inject, PipeTransform, Query as NestQuery } from '@nestjs/common' +import { REQUEST } from '@nestjs/core' +import { Class, Query } from '@ptc-org/nestjs-query-core' +import { plainToInstance } from 'class-transformer' + +import { Hook } from '../hooks' +import { HookContext } from '../interceptors' +import { MutationArgsType } from '../types' +import { BuildableQueryType } from '../types/query/buildable-query.type' + +class HooksTransformer implements PipeTransform { + @Inject(REQUEST) protected readonly request: Request + + public async transform(value: T, metadata: ArgumentMetadata): Promise | Query> { + const transformedValue = this.transformValue(value, metadata.metatype) + + if (metadata.type === 'query') { + return this.runQueryHooks(transformedValue as BuildableQueryType) + } + + return this.runMutationHooks(transformedValue) + } + + private transformValue(value: T, type?: Class): T { + if (!type || value instanceof type) { + return value + } + + return plainToInstance(type, value, { excludeExtraneousValues: true }) + } + + private async runMutationHooks(data: T): Promise> { + const hooks = (this.request as HookContext>).hooks + + if (hooks && hooks.length > 0) { + let hookedArgs = { input: data } + for (const hook of hooks) { + hookedArgs = (await hook.run(hookedArgs, this.request)) as MutationArgsType + } + + return hookedArgs + } + + return { input: data } + } + + private async runQueryHooks(data: BuildableQueryType): Promise> { + const hooks = (this.request as HookContext>).hooks + let hookedArgs = data.buildQuery() + + if (hooks && hooks.length > 0) { + for (const hook of hooks) { + hookedArgs = (await hook.run(hookedArgs, this.request)) as Query + } + + return hookedArgs + } + + return hookedArgs + } +} + +export const QueryHookArgs = >(): ParameterDecorator => { + return NestQuery(HooksTransformer) +} + +export const BodyHookArgs = >(): ParameterDecorator => { + return NestBody(HooksTransformer) +} diff --git a/packages/query-rest/src/decorators/hook.decorator.ts b/packages/query-rest/src/decorators/hook.decorator.ts new file mode 100644 index 000000000..0f723fd93 --- /dev/null +++ b/packages/query-rest/src/decorators/hook.decorator.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Class, getClassMetadata, MetaValue } from '@ptc-org/nestjs-query-core' + +import { + BeforeCreateOneHook, + BeforeQueryManyHook, + BeforeUpdateOneHook, + createDefaultHook, + Hook, + HookTypes, + isHookClass +} from '../hooks' + +export type HookMetaValue> = MetaValue[]> +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type HookDecoratorArg> = Class | H['run'] + +const hookMetaDataKey = (hookType: HookTypes): string => `nestjs-query:${hookType}` + +const hookDecorator = >(hookType: HookTypes) => { + const key = hookMetaDataKey(hookType) + + const getHook = (hook: HookDecoratorArg) => { + if (isHookClass(hook)) { + return hook + } + return createDefaultHook(hook) + } + + // eslint-disable-next-line @typescript-eslint/ban-types + return (...data: HookDecoratorArg[]) => + // eslint-disable-next-line @typescript-eslint/ban-types + (target: Function): void => { + return Reflect.defineMetadata( + key, + data.map((d) => getHook(d)), + target + ) + } +} + +export const BeforeCreateOne = hookDecorator>(HookTypes.BEFORE_CREATE_ONE) +// export const BeforeCreateMany = hookDecorator>(HookTypes.BEFORE_CREATE_MANY) +export const BeforeUpdateOne = hookDecorator>(HookTypes.BEFORE_UPDATE_ONE) +// export const BeforeUpdateMany = hookDecorator>(HookTypes.BEFORE_UPDATE_MANY) +// export const BeforeDeleteOne = hookDecorator(HookTypes.BEFORE_DELETE_ONE) +// export const BeforeDeleteMany = hookDecorator>(HookTypes.BEFORE_DELETE_MANY) +export const BeforeQueryMany = hookDecorator>(HookTypes.BEFORE_QUERY_MANY) +// export const BeforeFindOne = hookDecorator(HookTypes.BEFORE_FIND_ONE) + +export const getHooksForType = >(hookType: HookTypes, DTOClass: Class): HookMetaValue => + getClassMetadata(DTOClass, hookMetaDataKey(hookType), true) diff --git a/packages/query-rest/src/decorators/id-field.decorator.ts b/packages/query-rest/src/decorators/id-field.decorator.ts new file mode 100644 index 000000000..f4eb894d7 --- /dev/null +++ b/packages/query-rest/src/decorators/id-field.decorator.ts @@ -0,0 +1,55 @@ +import { Class, MetaValue, ValueReflector } from '@ptc-org/nestjs-query-core' + +import { Field, FieldOptions } from '../index' +import { ID_FIELD_KEY } from './constants' + +const reflector = new ValueReflector(ID_FIELD_KEY) + +export interface IDFieldOptions extends FieldOptions { + idOnly?: boolean +} + +export interface IDFieldDescriptor { + propertyName: string +} + +/** + * Decorator for Fields that should be filterable through a [[FilterType]] + * + * @example + * + * In the following DTO `id`, `title` and `completed` are filterable. + * + * ```ts + * import { IDField } from '@ptc-org/nestjs-query-rest'; + * + * export class TodoItemDTO { + * @IDField() + * id!: string; + * } + * ``` + */ +export function IDField(options?: IDFieldOptions): PropertyDecorator & MethodDecorator { + return ( + target: object, + propertyName: string | symbol, + descriptor?: TypedPropertyDescriptor + ): TypedPropertyDescriptor | void => { + reflector.set(target.constructor as Class, { + propertyName: propertyName.toString() + }) + + if (options?.idOnly) { + return + } + + if (descriptor) { + return Field(options)(target, propertyName, descriptor) + } + return Field(options)(target, propertyName) + } +} + +export function getIDField(DTOClass: Class): MetaValue { + return reflector.get(DTOClass, true) +} diff --git a/packages/query-rest/src/decorators/index.ts b/packages/query-rest/src/decorators/index.ts new file mode 100644 index 000000000..08b7aa578 --- /dev/null +++ b/packages/query-rest/src/decorators/index.ts @@ -0,0 +1,14 @@ +export * from './api-schema.decorator' +export * from './authorize-filter.decorator' +export * from './authorizer.decorator' +export * from './controller-methods.decorator' +export * from './field.decorator' +export * from './filterable-field.decorator' +export * from './hook.decorator' +export * from './hook-args.decorator' +export * from './id-field.decorator' +export * from './inject-authorizer.decorator' +export * from './query-options.decorator' +export * from './resolver-method.decorator' +export * from './resolver-query.decorator' +export * from './skip-if.decorator' diff --git a/packages/query-rest/src/decorators/inject-authorizer.decorator.ts b/packages/query-rest/src/decorators/inject-authorizer.decorator.ts new file mode 100644 index 000000000..a1d006295 --- /dev/null +++ b/packages/query-rest/src/decorators/inject-authorizer.decorator.ts @@ -0,0 +1,6 @@ +import { Inject } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +import { getAuthorizerToken } from '../auth' + +export const InjectAuthorizer = (DTOClass: Class): ParameterDecorator => Inject(getAuthorizerToken(DTOClass)) diff --git a/packages/query-rest/src/decorators/query-options.decorator.ts b/packages/query-rest/src/decorators/query-options.decorator.ts new file mode 100644 index 000000000..d614cd850 --- /dev/null +++ b/packages/query-rest/src/decorators/query-options.decorator.ts @@ -0,0 +1,18 @@ +import { Class, MetaValue, ValueReflector } from '@ptc-org/nestjs-query-core' + +import { QueryArgsTypeOpts } from '../types' +import { QUERY_OPTIONS_KEY } from './constants' + +const valueReflector = new ValueReflector(QUERY_OPTIONS_KEY) + +export type QueryOptionsDecoratorOpts = QueryArgsTypeOpts + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function QueryOptions(opts: QueryOptionsDecoratorOpts) { + return (target: Class): void => { + valueReflector.set(target, opts) + } +} + +export const getQueryOptions = (DTOClass: Class): MetaValue> => + valueReflector.get(DTOClass) diff --git a/packages/query-rest/src/decorators/resolver-method.decorator.ts b/packages/query-rest/src/decorators/resolver-method.decorator.ts new file mode 100644 index 000000000..bbc471402 --- /dev/null +++ b/packages/query-rest/src/decorators/resolver-method.decorator.ts @@ -0,0 +1,91 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + applyDecorators, + CanActivate, + ExceptionFilter, + NestInterceptor, + PipeTransform, + UseFilters, + UseGuards, + UseInterceptors, + UsePipes +} from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +export interface BaseResolverOptions { + /** An array of `nestjs` guards to apply to a endpoint */ + guards?: (Class | CanActivate)[] + /** An array of `nestjs` interceptors to apply to a endpoint */ + interceptors?: Class>[] + /** An array of `nestjs` pipes to apply to a endpoint */ + pipes?: Class>[] + /** An array of `nestjs` error filters to apply to a endpoint */ + filters?: Class>[] + /** An array of additional decorators to apply to the endpoint * */ + decorators?: (PropertyDecorator | MethodDecorator)[] + /** + * Tags to register for the endpoint + */ + tags?: string[] +} + +/** + * Options for resolver methods. + */ +export interface ResolverMethodOpts extends BaseResolverOptions { + /** Set to true to disable the endpoint */ + disabled?: boolean +} + +/** + * Options for relation resolver methods. + */ +export interface ResolverRelationMethodOpts extends BaseResolverOptions { + /** Set to true to enable the endpoint */ + enabled?: boolean +} + +/** + * @internal + * Creates a unique set of items. + * @param arrs - An array of arrays to de duplicate. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function createSetArray(...arrs: T[][]): T[] { + const set: Set = new Set(arrs.reduce((acc: T[], arr: T[]): T[] => [...acc, ...arr], [])) + return [...set] +} + +/** + * @internal + * Returns true if any of the [[ResolverMethodOpts]] are disabled. + * @param opts - The array of [[ResolverMethodOpts]] to check. + */ +export function isDisabled(opts: ResolverMethodOpts[]): boolean { + return !!opts.find((o) => o.disabled) +} + +/** + * @internal + * Returns true if any of the [[ResolverRelationMethodOpts]] are disabled. + * @param opts - The array of [[ResolverRelationMethodOpts]] to check. + */ +export function isEnabled(opts: ResolverRelationMethodOpts[]): boolean { + return opts.some((o) => o.enabled) +} + +/** + * @internal + * Decorator for all ResolverMethods + * + * @param opts - the [[ResolverMethodOpts]] to apply. + */ +export function ResolverMethod(...opts: ResolverMethodOpts[]): MethodDecorator { + return applyDecorators( + UseGuards(...createSetArray | CanActivate>(...opts.map((o) => o.guards ?? []))), + UseInterceptors(...createSetArray>(...opts.map((o) => o.interceptors ?? []))), + UsePipes(...createSetArray>(...opts.map((o) => o.pipes ?? []))), + UseFilters(...createSetArray>(...opts.map((o) => o.filters ?? []))), + ...createSetArray(...opts.map((o) => o.decorators ?? [])) + ) +} diff --git a/packages/query-rest/src/decorators/resolver-query.decorator.ts b/packages/query-rest/src/decorators/resolver-query.decorator.ts new file mode 100644 index 000000000..fc3952da9 --- /dev/null +++ b/packages/query-rest/src/decorators/resolver-query.decorator.ts @@ -0,0 +1,5 @@ +import { ResolverMethodOpts } from './resolver-method.decorator' + +export interface QueryResolverMethodOpts extends ResolverMethodOpts { + withDeleted?: boolean +} diff --git a/packages/query-rest/src/decorators/skip-if.decorator.ts b/packages/query-rest/src/decorators/skip-if.decorator.ts new file mode 100644 index 000000000..415ddb0ee --- /dev/null +++ b/packages/query-rest/src/decorators/skip-if.decorator.ts @@ -0,0 +1,14 @@ +import { ComposableDecorator, ComposedDecorator, composeDecorators } from './decorator.utils' + +/** + * @internal + * Wraps Args to allow skipping decorating + * @param check - checker to run. + * @param decorators - The decorators to apply + */ +export function SkipIf(check: () => boolean, ...decorators: ComposableDecorator[]): ComposedDecorator { + if (check()) { + return (): void => {} + } + return composeDecorators(...decorators) +} diff --git a/packages/query-rest/src/hooks/default.hook.ts b/packages/query-rest/src/hooks/default.hook.ts new file mode 100644 index 000000000..778987b81 --- /dev/null +++ b/packages/query-rest/src/hooks/default.hook.ts @@ -0,0 +1,13 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +import { Hook } from './hooks' + +export const createDefaultHook = (func: Hook['run']): Class> => { + class DefaultHook implements Hook { + get run() { + return func + } + } + + return DefaultHook +} diff --git a/packages/query-rest/src/hooks/hooks.ts b/packages/query-rest/src/hooks/hooks.ts new file mode 100644 index 000000000..c1cddefa9 --- /dev/null +++ b/packages/query-rest/src/hooks/hooks.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Class, Query } from '@ptc-org/nestjs-query-core' + +import { + // CreateManyInputType, + CreateOneInputType, + UpdateOneInputType + // DeleteManyInputType, + // DeleteOneInputType, + // FindOneArgsType, + // UpdateManyInputType, + // UpdateOneInputType +} from '../types' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface Hook { + run(instance: T, context: Context): T | Promise +} + +export function isHookClass(hook: unknown): hook is Class> { + return typeof hook === 'function' && 'prototype' in hook && 'run' in hook.prototype +} + +export type BeforeCreateOneHook = Hook, Context> +// export type BeforeCreateManyHook = Hook, Context> +// +export type BeforeUpdateOneHook = Hook, Context> +// export type BeforeUpdateManyHook = Hook, Context> +// +// export type BeforeDeleteOneHook = Hook +// export type BeforeDeleteManyHook = Hook, Context> +// +export type BeforeQueryManyHook = Hook, Context> +// +// export type BeforeFindOneHook = Hook diff --git a/packages/query-rest/src/hooks/index.ts b/packages/query-rest/src/hooks/index.ts new file mode 100644 index 000000000..5d8d36820 --- /dev/null +++ b/packages/query-rest/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { createDefaultHook } from './default.hook' +export * from './hooks' +export * from './tokens' +export * from './types' diff --git a/packages/query-rest/src/hooks/tokens.ts b/packages/query-rest/src/hooks/tokens.ts new file mode 100644 index 000000000..272afe5fd --- /dev/null +++ b/packages/query-rest/src/hooks/tokens.ts @@ -0,0 +1,5 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +import { HookTypes } from './types' + +export const getHookToken = (hookType: HookTypes, DTOClass: Class): string => `${DTOClass.name}${hookType}Hook` diff --git a/packages/query-rest/src/hooks/types.ts b/packages/query-rest/src/hooks/types.ts new file mode 100644 index 000000000..feb8e4435 --- /dev/null +++ b/packages/query-rest/src/hooks/types.ts @@ -0,0 +1,10 @@ +export enum HookTypes { + BEFORE_CREATE_ONE = 'BeforeCreateOne', + BEFORE_CREATE_MANY = 'BeforeCreateMany', + BEFORE_UPDATE_ONE = 'BeforeUpdateOne', + BEFORE_UPDATE_MANY = 'BeforeUpdateMany', + BEFORE_DELETE_ONE = 'BeforeDeleteOne', + BEFORE_DELETE_MANY = 'BeforeDeleteMany', + BEFORE_QUERY_MANY = 'BeforeQueryMany', + BEFORE_FIND_ONE = 'BeforeFindOne' +} diff --git a/packages/query-rest/src/index.ts b/packages/query-rest/src/index.ts new file mode 100644 index 000000000..61069e50b --- /dev/null +++ b/packages/query-rest/src/index.ts @@ -0,0 +1,7 @@ +export { AuthorizationContext, Authorizer, AuthorizerOptions, CustomAuthorizer, OperationGroup } from './auth' +export * from './connection' +export * from './decorators' +export * from './hooks' +export * from './interceptors' +export { NestjsQueryRestModule } from './module' +export * from './types' diff --git a/packages/query-rest/src/interceptors/authorizer.interceptor.ts b/packages/query-rest/src/interceptors/authorizer.interceptor.ts new file mode 100644 index 000000000..f2da249b3 --- /dev/null +++ b/packages/query-rest/src/interceptors/authorizer.interceptor.ts @@ -0,0 +1,29 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +import { Authorizer } from '../auth' +import { InjectAuthorizer } from '../decorators' + +export type AuthorizerContext = { authorizer: Authorizer } + +export function AuthorizerInterceptor(DTOClass: Class): Class { + @Injectable() + class Interceptor implements NestInterceptor { + constructor(@InjectAuthorizer(DTOClass) readonly authorizer: Authorizer) {} + + public intercept(context: ExecutionContext, next: CallHandler) { + const request = context.switchToHttp().getRequest>() + request.authorizer = this.authorizer + + return next.handle() + } + } + + Object.defineProperty(Interceptor, 'name', { + writable: false, + // set a unique name otherwise DI does not inject a unique one for each request + value: `${DTOClass.name}AuthorizerInterceptor` + }) + + return Interceptor +} diff --git a/packages/query-rest/src/interceptors/hook.interceptor.ts b/packages/query-rest/src/interceptors/hook.interceptor.ts new file mode 100644 index 000000000..4b9fbeed2 --- /dev/null +++ b/packages/query-rest/src/interceptors/hook.interceptor.ts @@ -0,0 +1,43 @@ +import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +import { getHooksForType } from '../decorators' +import { getHookToken, Hook, HookTypes } from '../hooks' + +export type HookContext> = { + hooks?: H[] +} + +class DefaultHookInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + return next.handle() + } +} + +export function HookInterceptor(type: HookTypes, ...DTOClasses: Class[]): Class { + const HookedClasses = DTOClasses.find((Cls) => getHooksForType(type, Cls)) + if (!HookedClasses) { + return DefaultHookInterceptor + } + const hookToken = getHookToken(type, HookedClasses) + + @Injectable() + class Interceptor implements NestInterceptor { + constructor(@Inject(hookToken) readonly hooks: Hook[]) {} + + public intercept(context: ExecutionContext, next: CallHandler) { + const request = context.switchToHttp().getRequest>>() + request.hooks = this.hooks + + return next.handle() + } + } + + Object.defineProperty(Interceptor, 'name', { + writable: false, + // set a unique name otherwise DI does not inject a unique one for each request + value: `${DTOClasses[0].name}${type}HookInterceptor` + }) + + return Interceptor +} diff --git a/packages/query-rest/src/interceptors/index.ts b/packages/query-rest/src/interceptors/index.ts new file mode 100644 index 000000000..93a26d136 --- /dev/null +++ b/packages/query-rest/src/interceptors/index.ts @@ -0,0 +1,2 @@ +export * from './authorizer.interceptor' +export * from './hook.interceptor' diff --git a/packages/query-rest/src/interfaces/return-type-func.ts b/packages/query-rest/src/interfaces/return-type-func.ts new file mode 100644 index 000000000..eced9a628 --- /dev/null +++ b/packages/query-rest/src/interfaces/return-type-func.ts @@ -0,0 +1,6 @@ +import { Type } from '@nestjs/common' + +// eslint-disable-next-line @typescript-eslint/ban-types +export type ReturnTypeFuncValue = Type | Function | object | symbol +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ReturnTypeFunc = (returns?: void) => T diff --git a/packages/query-rest/src/module.ts b/packages/query-rest/src/module.ts new file mode 100644 index 000000000..3b0104b94 --- /dev/null +++ b/packages/query-rest/src/module.ts @@ -0,0 +1,74 @@ +import { DynamicModule, ForwardReference, Provider, Type } from '@nestjs/common' +import { Assembler, Class, NestjsQueryCoreModule } from '@ptc-org/nestjs-query-core' + +import { createAuthorizerProviders } from './providers' +import { createHookProviders } from './providers/hook.provider' +import { AutoResolverOpts, createEndpoints } from './providers/resolver.provider' +import { ReadResolverOpts } from './resolvers' +import { PagingStrategies } from './types' + +interface DTOModuleOpts { + DTOClass: Class +} + +export interface NestjsQueryRestModuleFeatureOpts { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + imports?: Array | DynamicModule | Promise | ForwardReference> + services?: Provider[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assemblers?: Class>[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endpoints?: AutoResolverOpts, PagingStrategies>[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // resolvers?: AutoResolverOpts, PagingStrategies>[] + dtos?: DTOModuleOpts[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + controllers?: Array> + // pubSub?: Provider +} + +export class NestjsQueryRestModule { + public static forFeature(opts: NestjsQueryRestModuleFeatureOpts): DynamicModule { + const coreModule = this.getCoreModule(opts) + const providers = this.getProviders(opts) + const imports = opts.imports ?? [] + const controllers = opts.controllers ?? [] + + return { + module: NestjsQueryRestModule, + imports: [...imports, coreModule], + providers: [...providers], + exports: [...providers, ...imports, coreModule], + controllers: [...this.getEndpointProviders(opts), ...controllers] + } + } + + private static getCoreModule(opts: NestjsQueryRestModuleFeatureOpts): DynamicModule { + return NestjsQueryCoreModule.forFeature({ + assemblers: opts.assemblers, + imports: opts.imports ?? [] + }) + } + + private static getProviders(opts: NestjsQueryRestModuleFeatureOpts): Provider[] { + return [...this.getServicesProviders(opts), ...this.getAuthorizerProviders(opts), ...this.getHookProviders(opts)] + } + + private static getServicesProviders(opts: NestjsQueryRestModuleFeatureOpts): Provider[] { + return opts.services ?? [] + } + + private static getAuthorizerProviders(opts: NestjsQueryRestModuleFeatureOpts): Provider[] { + const endpointDTOs = opts.endpoints?.map((r) => r.DTOClass) ?? [] + const dtos = opts.dtos?.map((o) => o.DTOClass) ?? [] + return createAuthorizerProviders([...endpointDTOs, ...dtos]) + } + + private static getEndpointProviders(opts: NestjsQueryRestModuleFeatureOpts): Type[] { + return createEndpoints(opts.endpoints ?? []) + } + + private static getHookProviders(opts: NestjsQueryRestModuleFeatureOpts): Provider[] { + return createHookProviders([...(opts.endpoints ?? []), ...(opts.dtos ?? [])]) + } +} diff --git a/packages/query-rest/src/providers/authorizer.provider.ts b/packages/query-rest/src/providers/authorizer.provider.ts new file mode 100644 index 000000000..e06e50f4c --- /dev/null +++ b/packages/query-rest/src/providers/authorizer.provider.ts @@ -0,0 +1,34 @@ +import { Provider } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +import { createDefaultAuthorizer, getAuthorizerToken, getCustomAuthorizerToken } from '../auth' +import { getAuthorizer, getCustomAuthorizer } from '../decorators' + +function createServiceProvider(DTOClass: Class): Provider { + const token = getAuthorizerToken(DTOClass) + const authorizer = getAuthorizer(DTOClass) + if (!authorizer) { + // create default authorizer in case any relations have an authorizers + return { provide: token, useClass: createDefaultAuthorizer(DTOClass, { authorize: () => ({}) }) } + } + return { provide: token, useClass: authorizer } +} + +function createCustomAuthorizerProvider(DTOClass: Class): Provider | undefined { + const token = getCustomAuthorizerToken(DTOClass) + const customAuthorizer = getCustomAuthorizer(DTOClass) + if (customAuthorizer) { + return { provide: token, useClass: customAuthorizer } + } + return undefined +} + +export const createAuthorizerProviders = (DTOClasses: Class[]): Provider[] => + DTOClasses.reduce((providers, DTOClass) => { + const p = createCustomAuthorizerProvider(DTOClass) + if (p) { + providers.push(p) + } + providers.push(createServiceProvider(DTOClass)) + return providers + }, []) diff --git a/packages/query-rest/src/providers/hook.provider.ts b/packages/query-rest/src/providers/hook.provider.ts new file mode 100644 index 000000000..8a155784d --- /dev/null +++ b/packages/query-rest/src/providers/hook.provider.ts @@ -0,0 +1,49 @@ +import { Provider } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +import { getHooksForType } from '../decorators' +import { getHookToken, HookTypes } from '../hooks' +import { PagingStrategies } from '../types' +import { CRUDAutoResolverOpts } from './resolver.provider' + +export type HookProviderOptions = Pick< + CRUDAutoResolverOpts, + 'DTOClass' | 'CreateDTOClass' | 'UpdateDTOClass' +> + +function createHookProvider(hookType: HookTypes, ...DTOClass: Class[]): Provider[] | undefined { + return DTOClass.reduce((p: Provider[] | undefined, cls) => { + if (p && p.length > 0) { + return p + } + const maybeHooks = getHooksForType(hookType, cls) + if (maybeHooks) { + return [ + ...maybeHooks, + { + provide: getHookToken(hookType, cls), + useFactory: (...providers: Provider[]) => providers, + inject: maybeHooks + } + ] + } + return [] + }, []) +} + +function getHookProviders(opts: HookProviderOptions): Provider[] { + const { DTOClass, CreateDTOClass = DTOClass, UpdateDTOClass = DTOClass } = opts + return [ + ...createHookProvider(HookTypes.BEFORE_CREATE_ONE, CreateDTOClass, DTOClass), + ...createHookProvider(HookTypes.BEFORE_CREATE_MANY, CreateDTOClass, DTOClass), + ...createHookProvider(HookTypes.BEFORE_UPDATE_ONE, UpdateDTOClass, DTOClass), + ...createHookProvider(HookTypes.BEFORE_UPDATE_MANY, UpdateDTOClass, DTOClass), + ...createHookProvider(HookTypes.BEFORE_DELETE_ONE, DTOClass), + ...createHookProvider(HookTypes.BEFORE_DELETE_MANY, DTOClass), + ...createHookProvider(HookTypes.BEFORE_QUERY_MANY, DTOClass), + ...createHookProvider(HookTypes.BEFORE_FIND_ONE, DTOClass) + ].filter((p) => !!p) +} + +export const createHookProviders = (opts: HookProviderOptions[]): Provider[] => + opts.reduce((ps: Provider[], opt) => [...ps, ...getHookProviders(opt)], []) diff --git a/packages/query-rest/src/providers/index.ts b/packages/query-rest/src/providers/index.ts new file mode 100644 index 000000000..e01983edc --- /dev/null +++ b/packages/query-rest/src/providers/index.ts @@ -0,0 +1 @@ +export * from './authorizer.provider' diff --git a/packages/query-rest/src/providers/resolver.provider.ts b/packages/query-rest/src/providers/resolver.provider.ts new file mode 100644 index 000000000..9cceabff3 --- /dev/null +++ b/packages/query-rest/src/providers/resolver.provider.ts @@ -0,0 +1,149 @@ +import { Controller, Inject, Type } from '@nestjs/common' +import { + Assembler, + AssemblerFactory, + AssemblerQueryService, + Class, + DeepPartial, + InjectAssemblerQueryService, + InjectQueryService, + QueryService +} from '@ptc-org/nestjs-query-core' + +import { getDTONames } from '../common' +import { CRUDResolver, CRUDResolverOpts } from '../resolvers' +import { PagingStrategies } from '../types' + +export type CRUDAutoResolverOpts = CRUDResolverOpts & { + DTOClass: Class +} + +export type EntityCRUDAutoResolverOpts = CRUDAutoResolverOpts< + DTO, + C, + U, + R, + PS +> & { + EntityClass: Class +} + +export type AssemblerCRUDAutoResolverOpts = CRUDAutoResolverOpts< + DTO, + C, + U, + R, + PS +> & { + AssemblerClass: Class +} + +export type ServiceCRUDAutoResolverOpts = CRUDAutoResolverOpts< + DTO, + C, + U, + R, + PS +> & { + ServiceClass: Class +} + +export type AutoResolverOpts = + | EntityCRUDAutoResolverOpts + | AssemblerCRUDAutoResolverOpts + | ServiceCRUDAutoResolverOpts + +export const isServiceCRUDAutoResolverOpts = ( + opts: AutoResolverOpts +): opts is ServiceCRUDAutoResolverOpts => 'DTOClass' in opts && 'ServiceClass' in opts + +export const isAssemblerCRUDAutoResolverOpts = ( + opts: AutoResolverOpts +): opts is AssemblerCRUDAutoResolverOpts => 'DTOClass' in opts && 'AssemblerClass' in opts + +const getEndpointToken = (DTOClass: Class): string => `${DTOClass.name}AutoEndpoint` + +function createEntityAutoResolver, C, U, R, PS extends PagingStrategies>( + resolverOpts: EntityCRUDAutoResolverOpts +): Type { + const { DTOClass, EntityClass, basePath } = resolverOpts + const { endpointName } = getDTONames(DTOClass) + + class Service extends AssemblerQueryService { + constructor(service: QueryService) { + const assembler = AssemblerFactory.getAssembler(DTOClass, EntityClass) + super(assembler, service) + } + } + + @Controller(basePath || endpointName) + class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { + constructor(@InjectQueryService(EntityClass) service: QueryService) { + super(new Service(service)) + } + } + + // need to set class name so DI works properly + Object.defineProperty(AutoResolver, 'name', { value: getEndpointToken(DTOClass), writable: false }) + return AutoResolver +} + +function createAssemblerAutoResolver( + resolverOpts: AssemblerCRUDAutoResolverOpts +): Type { + const { DTOClass, AssemblerClass, basePath } = resolverOpts + const { endpointName } = getDTONames(DTOClass) + + @Controller(basePath || endpointName) + class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { + constructor( + @InjectAssemblerQueryService(AssemblerClass as unknown as Class>) + service: QueryService + ) { + super(service) + } + } + + // need to set class name so DI works properly + Object.defineProperty(AutoResolver, 'name', { value: getEndpointToken(DTOClass), writable: false }) + return AutoResolver +} + +function createServiceAutoResolver( + resolverOpts: ServiceCRUDAutoResolverOpts +): Type { + const { DTOClass, ServiceClass, basePath } = resolverOpts + const { endpointName } = getDTONames(DTOClass) + + @Controller(basePath || endpointName) + class AutoResolver extends CRUDResolver(DTOClass, resolverOpts) { + constructor(@Inject(ServiceClass) service: QueryService) { + super(service) + } + } + + // need to set class name so DI works properly + Object.defineProperty(AutoResolver, 'name', { value: getEndpointToken(DTOClass), writable: false }) + return AutoResolver +} + +function createEndpoint< + DTO, + EntityServiceOrAssembler extends DeepPartial, + C, + U, + R, + PS extends PagingStrategies +>(resolverOpts: AutoResolverOpts): Type { + if (isAssemblerCRUDAutoResolverOpts(resolverOpts)) { + return createAssemblerAutoResolver(resolverOpts) + } else if (isServiceCRUDAutoResolverOpts(resolverOpts)) { + return createServiceAutoResolver(resolverOpts) + } + + return createEntityAutoResolver(resolverOpts) +} + +export const createEndpoints = ( + opts: AutoResolverOpts[] +): Type[] => opts.map((opt) => createEndpoint(opt)) diff --git a/packages/query-rest/src/resolvers/create.resolver.ts b/packages/query-rest/src/resolvers/create.resolver.ts new file mode 100644 index 000000000..02c81b867 --- /dev/null +++ b/packages/query-rest/src/resolvers/create.resolver.ts @@ -0,0 +1,128 @@ +// eslint-disable-next-line max-classes-per-file +import { OmitType } from '@nestjs/swagger' +import { Class, DeepPartial, Filter, QueryService } from '@ptc-org/nestjs-query-core' +import omit from 'lodash.omit' + +import { DTONames, getDTONames } from '../common' +import { ApiSchema, BodyHookArgs, Post } from '../decorators' +import { HookTypes } from '../hooks' +import { AuthorizerInterceptor, HookInterceptor } from '../interceptors' +import { CreateOneInputType, MutationArgsType } from '../types' +import { BaseServiceResolver, MutationOpts, ResolverClass, ServiceResolver } from './resolver.interface' + +export interface CreateResolverOpts> extends MutationOpts { + /** + * The Input DTO that should be used to create records. + */ + CreateDTOClass?: Class + /** + * The class to be used for `createOne` input. + */ + CreateOneInput?: Class> +} + +export interface CreateResolver> extends ServiceResolver { + createOne(input: MutationArgsType>, authorizeFilter?: Filter): Promise +} + +/** @internal */ +const defaultCreateDTO = (dtoNames: DTONames, DTOClass: Class): Class => { + @ApiSchema({ name: `Create${dtoNames.baseName}` }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + class DefaultCreateDTO extends OmitType(DTOClass, []) {} + + return DefaultCreateDTO as Class +} + +/** @internal */ +const defaultCreateOneInput = (dtoNames: DTONames, InputDTO: Class): Class> => { + class CO extends CreateOneInputType(InputDTO) {} + + return CO +} + +/** + * @internal + * Mixin to add `create` graphql endpoints. + */ +export const Creatable = + >(DTOClass: Class, opts: CreateResolverOpts) => + >>(BaseClass: B): Class> & B => { + if (opts.disabled) { + return BaseClass as never + } + + const dtoNames = getDTONames(DTOClass, opts) + + const { + CreateDTOClass = defaultCreateDTO(dtoNames, DTOClass), + CreateOneInput = defaultCreateOneInput(dtoNames, CreateDTOClass) + } = opts + + const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'CreateDTOClass', 'CreateOneInput', 'CreateManyInput') + + class COI extends MutationArgsType(CreateOneInput) {} + + class CreateControllerBase extends BaseClass { + @Post( + () => DTOClass, + { + disabled: opts.disabled, + path: opts.one?.path, + operation: { + operationId: `${dtoNames.pluralBaseNameLower}.createOne`, + tags: [...(opts.tags || []), ...(opts.one?.tags ?? [])], + description: opts?.one?.description, + ...opts?.one?.operationOptions + }, + body: { + type: CreateDTOClass + } + }, + { + interceptors: [HookInterceptor(HookTypes.BEFORE_CREATE_ONE, CreateDTOClass, DTOClass), AuthorizerInterceptor(DTOClass)] + }, + commonResolverOpts, + opts.one ?? {} + ) + public async createOne(@BodyHookArgs() { input }: COI): Promise { + // Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.service.createOne(input) + } + } + + return CreateControllerBase + } + +/** + * Factory to create a new abstract class that can be extended to add `create` endpoints. + * + * Assume we have `TodoItemDTO`, you can create a resolver with `createOneTodoItem` and `createManyTodoItems` graphql + * query endpoints using the following code. + * + * ```ts + * @Controller() + * export class TodoItemResolver extends CreateResolver(TodoItemDTO) { + * constructor(readonly service: TodoItemService) { + * super(service); + * } + * } + * ``` + * + * @param DTOClass - The DTO class that should be returned from the `createOne` and `createMany` endpoint. + * @param opts - Options to customize endpoints. + * @typeparam DTO - The type of DTO that should be created. + * @typeparam C - The create DTO type. + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const CreateResolver = < + DTO, + C = DeepPartial, + QS extends QueryService = QueryService +>( + DTOClass: Class, + opts: CreateResolverOpts = {} +): ResolverClass> => Creatable(DTOClass, opts)(BaseServiceResolver) diff --git a/packages/query-rest/src/resolvers/crud.resolver.ts b/packages/query-rest/src/resolvers/crud.resolver.ts new file mode 100644 index 000000000..cbf3318a2 --- /dev/null +++ b/packages/query-rest/src/resolvers/crud.resolver.ts @@ -0,0 +1,127 @@ +import { Class, DeepPartial, QueryService } from '@ptc-org/nestjs-query-core' + +import { mergeBaseResolverOpts } from '../common' +import { ConnectionOptions } from '../connection/interfaces' +import { BaseResolverOptions } from '../decorators' +import { PagingStrategies } from '../types' +import { CreateResolver, CreateResolverOpts } from './create.resolver' +import { Deletable, DeleteResolverOpts } from './delete.resolver' +import { Readable, ReadResolverFromOpts, ReadResolverOpts } from './read.resolver' +import { MergePagingStrategyOpts, ResolverClass } from './resolver.interface' +import { Updateable, UpdateResolver, UpdateResolverOpts } from './update.resolver' + +export interface CRUDResolverOpts< + DTO, + C = DeepPartial, + U = DeepPartial, + R = ReadResolverOpts, + PS extends PagingStrategies = PagingStrategies.NONE +> extends BaseResolverOptions, + Pick { + /** + * The DTO that should be used as input for create endpoints. + */ + CreateDTOClass?: Class + /** + * The DTO that should be used as input for update endpoints. + */ + UpdateDTOClass?: Class + /** + * The DTO that should be used for filter of the aggregate endpoint. + */ + // AggregateDTOClass?: Class + pagingStrategy?: PS + create?: CreateResolverOpts + read?: R + update?: UpdateResolverOpts + delete?: DeleteResolverOpts + + basePath?: string + tags?: string[] +} + +export interface CRUDResolver< + DTO, + C, + U, + R extends ReadResolverOpts, + QS extends QueryService = QueryService +> extends CreateResolver, + ReadResolverFromOpts, + UpdateResolver {} + +// DeleteResolver, +// AggregateResolver { + +// function extractAggregateResolverOpts( +// opts: CRUDResolverOpts, PagingStrategies> +// ): AggregateResolverOpts { +// const { AggregateDTOClass, enableAggregate, aggregate } = opts +// return mergeBaseResolverOpts>({ enabled: enableAggregate, AggregateDTOClass, ...aggregate }, opts) +// } + +function extractCreateResolverOpts( + opts: CRUDResolverOpts, PagingStrategies> +): CreateResolverOpts { + const { CreateDTOClass, create } = opts + return mergeBaseResolverOpts>({ CreateDTOClass, ...create }, opts) +} + +function extractReadResolverOpts, PS extends PagingStrategies>( + opts: CRUDResolverOpts +): MergePagingStrategyOpts { + const { enableTotalCount, pagingStrategy, read } = opts + return mergeBaseResolverOpts({ enableTotalCount, pagingStrategy, ...read } as MergePagingStrategyOpts, opts) +} + +function extractUpdateResolverOpts( + opts: CRUDResolverOpts, PagingStrategies> +): UpdateResolverOpts { + const { UpdateDTOClass, update } = opts + return mergeBaseResolverOpts>({ UpdateDTOClass, ...update }, opts) +} + +function extractDeleteResolverOpts( + opts: CRUDResolverOpts, PagingStrategies> +): DeleteResolverOpts { + const { delete: deleteArgs = {} } = opts + return mergeBaseResolverOpts>(deleteArgs, opts) +} + +/** + * Factory to create a resolver that includes all CRUD methods from [[CreateResolver]], [[ReadResolver]], + * [[UpdateResolver]], and [[DeleteResolver]]. + * + * ```ts + * import { CRUDResolver } from '@ptc-org/nestjs-query-graphql'; + * import { Resolver } from '@nestjs/graphql'; + * import { TodoItemDTO } from './dto/todo-item.dto'; + * import { TodoItemService } from './todo-item.service'; + * + * @Resolver() + * export class TodoItemResolver extends CRUDResolver(TodoItemDTO) { + * constructor(readonly service: TodoItemService) { + * super(service); + * } + * } + * ``` + * @param DTOClass - The DTO Class that the resolver is for. All methods will use types derived from this class. + * @param opts - Options to customize the resolver. + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const CRUDResolver = < + DTO, + C = DeepPartial, + U = DeepPartial, + R extends ReadResolverOpts = ReadResolverOpts, + PS extends PagingStrategies = PagingStrategies.NONE +>( + DTOClass: Class, + opts: CRUDResolverOpts = {} +): ResolverClass, CRUDResolver>> => { + const readable = Readable(DTOClass, extractReadResolverOpts(opts)) + const updatable = Updateable(DTOClass, extractUpdateResolverOpts(opts)) + const deleteResolver = Deletable(DTOClass, extractDeleteResolverOpts(opts)) + + return readable(deleteResolver(updatable(CreateResolver(DTOClass, extractCreateResolverOpts(opts))))) +} diff --git a/packages/query-rest/src/resolvers/delete.resolver.ts b/packages/query-rest/src/resolvers/delete.resolver.ts new file mode 100644 index 000000000..d460618f9 --- /dev/null +++ b/packages/query-rest/src/resolvers/delete.resolver.ts @@ -0,0 +1,81 @@ +// eslint-disable-next-line max-classes-per-file +import { Param } from '@nestjs/common' +import { Class, Filter, QueryService } from '@ptc-org/nestjs-query-core' +import omit from 'lodash.omit' + +import { OperationGroup } from '../auth' +import { getDTONames } from '../common' +import { AuthorizerFilter, Delete } from '../decorators' +import { AuthorizerInterceptor } from '../interceptors' +import { FindOneArgsType } from '../types/find-one-args.type' +import { BaseServiceResolver, MutationOpts, ResolverClass, ServiceResolver } from './resolver.interface' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface DeleteResolverOpts extends MutationOpts { + /** + * Use soft delete when doing delete mutation + */ + useSoftDelete?: boolean +} + +export interface DeleteResolver> extends ServiceResolver { + deleteOne(id: FindOneArgsType, authorizeFilter?: Filter): Promise> +} + +/** + * @internal + * Mixin to add `delete` graphql endpoints. + */ +export const Deletable = + >(DTOClass: Class, opts: DeleteResolverOpts) => + >>(BaseClass: B): Class> & B => { + const dtoNames = getDTONames(DTOClass, opts) + + const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'DeleteOneInput', 'DeleteManyInput', 'useSoftDelete') + + class DOP extends FindOneArgsType(DTOClass) {} + + Object.defineProperty(DOP, 'name', { + writable: false, + // set a unique name otherwise DI does not inject a unique one for each request + value: `FindDelete${DTOClass.name}Args` + }) + + class DeleteResolverBase extends BaseClass { + @Delete( + () => DTOClass, + { + path: opts?.one?.path ?? ':id', + operation: { + operationId: `${dtoNames.pluralBaseNameLower}.deleteOne`, + tags: [...(opts.tags || []), ...(opts.one?.tags ?? [])], + description: opts?.one?.description, + ...opts?.one?.operationOptions + } + }, + { interceptors: [AuthorizerInterceptor(DTOClass)] }, + commonResolverOpts, + opts.one ?? {} + ) + async deleteOne( + @Param() params: DOP, + @AuthorizerFilter({ + operationGroup: OperationGroup.DELETE, + many: false + }) + authorizeFilter?: Filter + ): Promise> { + return this.service.deleteOne(params.id, { + filter: authorizeFilter ?? {}, + useSoftDelete: opts?.useSoftDelete ?? false + }) + } + } + + return DeleteResolverBase + } +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const DeleteResolver = = QueryService>( + DTOClass: Class, + opts: DeleteResolverOpts = {} +): ResolverClass> => Deletable(DTOClass, opts)(BaseServiceResolver) diff --git a/packages/query-rest/src/resolvers/index.ts b/packages/query-rest/src/resolvers/index.ts new file mode 100644 index 000000000..ca1fd25d6 --- /dev/null +++ b/packages/query-rest/src/resolvers/index.ts @@ -0,0 +1,6 @@ +export { CreateResolver, CreateResolverOpts } from './create.resolver' +export { CRUDResolver, CRUDResolverOpts } from './crud.resolver' +// export { DeleteResolver, DeleteResolverOpts } from './delete.resolver' +export { ReadResolver, ReadResolverOpts } from './read.resolver' +export { ResolverOpts } from './resolver.interface' +// export { UpdateResolver, UpdateResolverOpts } from './update.resolver' diff --git a/packages/query-rest/src/resolvers/read.resolver.ts b/packages/query-rest/src/resolvers/read.resolver.ts new file mode 100644 index 000000000..e788bb16a --- /dev/null +++ b/packages/query-rest/src/resolvers/read.resolver.ts @@ -0,0 +1,159 @@ +import { Param } from '@nestjs/common' +import { Class, Filter, mergeQuery, QueryService } from '@ptc-org/nestjs-query-core' +import omit from 'lodash.omit' + +import { OperationGroup } from '../auth' +import { getDTONames } from '../common' +import { ConnectionOptions, InferConnectionTypeFromStrategy } from '../connection/interfaces' +import { AuthorizerFilter, Get, QueryHookArgs } from '../decorators' +import { HookTypes } from '../hooks' +import { AuthorizerInterceptor, HookInterceptor } from '../interceptors' +import { QueryArgsType } from '../types' +import { FindOneArgsType } from '../types/find-one-args.type' +import { OffsetQueryArgsTypeOpts, PagingStrategies, QueryArgsTypeOpts, QueryType, StaticQueryType } from '../types/query' +import { BaseServiceResolver, ExtractPagingStrategy, ResolverClass, ResolverOpts, ServiceResolver } from './resolver.interface' + +export type ReadResolverFromOpts< + DTO, + Opts extends ReadResolverOpts, + QS extends QueryService +> = ReadResolver, QS> + +export type ReadResolverOpts = { + QueryArgs?: StaticQueryType + + /** + * DTO to return with finding one record + */ + FindDTOClass?: Class +} & ResolverOpts & + QueryArgsTypeOpts & + Pick + +export interface ReadResolver> + extends ServiceResolver { + queryMany( + query: QueryType, + authorizeFilter?: Filter + ): Promise> + + findById(id: FindOneArgsType, authorizeFilter?: Filter): Promise +} + +/** + * @internal + * Mixin to add `read` graphql endpoints. + */ +export const Readable = + , QS extends QueryService>( + DTOClass: Class, + opts: ReadOpts + ) => + >>(BaseClass: B): Class> & B => { + if (opts.disabled) { + return BaseClass as never + } + + const dtoNames = getDTONames(DTOClass, opts) + const { + QueryArgs = QueryArgsType(DTOClass, { ...opts, connectionName: `${dtoNames.baseName}Connection` }), + FindDTOClass = DTOClass + } = opts + + const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'QueryArgs', 'FindDTOClass', 'Connection', 'withDeleted') + + class QA extends QueryArgs {} + + class FOP extends FindOneArgsType(FindDTOClass) {} + + Object.defineProperty(QA, 'name', { + writable: false, + // set a unique name otherwise DI does not inject a unique one for each request + value: `Query${DTOClass.name}Args` + }) + + Object.defineProperty(FOP, 'name', { + writable: false, + // set a unique name otherwise DI does not inject a unique one for each request + value: `Find${DTOClass.name}Args` + }) + + class ReadResolverBase extends BaseClass { + @Get( + () => FindDTOClass, + { + path: opts?.one?.path ?? ':id', + operation: { + operationId: `${dtoNames.pluralBaseNameLower}.findById`, + tags: [...(opts.tags || []), ...(opts.one?.tags ?? [])], + description: opts?.one?.description, + ...opts?.one?.operationOptions + } + }, + { interceptors: [HookInterceptor(HookTypes.BEFORE_FIND_ONE, DTOClass), AuthorizerInterceptor(DTOClass)] }, + commonResolverOpts, + opts.one ?? {} + ) + public async findById( + @Param() params: FOP, + @AuthorizerFilter({ + operationGroup: OperationGroup.READ, + many: false + }) + authorizeFilter?: Filter + ): Promise { + return this.service.getById(params.id, { + filter: authorizeFilter, + withDeleted: opts?.one?.withDeleted + }) + } + + @Get( + () => QueryArgs.ConnectionType, + { + path: opts?.many?.path, + operation: { + operationId: `${dtoNames.pluralBaseNameLower}.queryMany`, + tags: [...(opts.tags || []), ...(opts.many?.tags ?? [])], + description: opts?.many?.description, + ...opts?.many?.operationOptions + } + }, + { interceptors: [HookInterceptor(HookTypes.BEFORE_QUERY_MANY, DTOClass), AuthorizerInterceptor(DTOClass)] }, + commonResolverOpts, + opts.many ?? {} + ) + public async queryMany( + @QueryHookArgs() query: QA, + @AuthorizerFilter({ + operationGroup: OperationGroup.READ, + many: true + }) + authorizeFilter?: Filter + ): Promise> { + return QueryArgs.ConnectionType.createFromPromise( + (q) => + this.service.query(q, { + withDeleted: opts?.many?.withDeleted + }), + mergeQuery(query, { filter: authorizeFilter }), + (filter) => + this.service.count(filter, { + withDeleted: opts?.many?.withDeleted + }) + ) + } + } + + return ReadResolverBase as Class> & B + } + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const ReadResolver = < + DTO, + ReadOpts extends ReadResolverOpts = OffsetQueryArgsTypeOpts, + QS extends QueryService = QueryService +>( + DTOClass: Class, + opts: ReadOpts = {} as ReadOpts +): ResolverClass> => Readable(DTOClass, opts)(BaseServiceResolver) diff --git a/packages/query-rest/src/resolvers/resolver.interface.ts b/packages/query-rest/src/resolvers/resolver.interface.ts new file mode 100644 index 000000000..629a6fef2 --- /dev/null +++ b/packages/query-rest/src/resolvers/resolver.interface.ts @@ -0,0 +1,62 @@ +import { ApiOperationOptions } from '@nestjs/swagger' +import { QueryService } from '@ptc-org/nestjs-query-core' + +import { DTONamesOpts } from '../common' +import { QueryOptionsDecoratorOpts, QueryResolverMethodOpts } from '../decorators' +import { PagingStrategies, QueryArgsTypeOpts } from '../types/query' + +export type NamedEndpoint = { + /** Specify to override the name of the graphql query or mutation * */ + path?: string + /** Specify a description for the graphql query or mutation* */ + description?: string + operationOptions?: ApiOperationOptions +} + +export interface ResolverOpts extends QueryResolverMethodOpts, DTONamesOpts { + /** + * Options for single record graphql endpoints + */ + one?: QueryResolverMethodOpts & NamedEndpoint + /** + * Options for multiple record graphql endpoints + */ + many?: QueryResolverMethodOpts & NamedEndpoint +} + +export type MutationOpts = Omit + +/** @internal */ +export interface ServiceResolver> { + service: QS +} + +/** @internal */ +export interface ResolverClass, Resolver extends ServiceResolver> { + new (service: QS): Resolver +} + +/** + * @internal + * Base Resolver that takes in a service as a constructor argument. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class BaseServiceResolver { + constructor(readonly service: QS) {} +} + +export type ExtractPagingStrategy> = Opts['pagingStrategy'] extends PagingStrategies + ? Opts['pagingStrategy'] + : PagingStrategies.NONE + +export type MergePagingStrategyOpts< + DTO, + Opts extends QueryOptionsDecoratorOpts, + S extends PagingStrategies = PagingStrategies.NONE +> = Opts['pagingStrategy'] extends PagingStrategies + ? Opts + : S extends PagingStrategies + ? Omit & { + pagingStrategy: S + } + : Opts diff --git a/packages/query-rest/src/resolvers/update.resolver.ts b/packages/query-rest/src/resolvers/update.resolver.ts new file mode 100644 index 000000000..7197827c9 --- /dev/null +++ b/packages/query-rest/src/resolvers/update.resolver.ts @@ -0,0 +1,109 @@ +// eslint-disable-next-line max-classes-per-file +import { Param } from '@nestjs/common' +import { PartialType } from '@nestjs/swagger' +import { Class, DeepPartial, Filter, QueryService } from '@ptc-org/nestjs-query-core' +import omit from 'lodash.omit' + +import { OperationGroup } from '../auth' +import { DTONames, getDTONames } from '../common' +import { ApiSchema, AuthorizerFilter, BodyHookArgs, Put } from '../decorators' +import { HookTypes } from '../hooks' +import { AuthorizerInterceptor, HookInterceptor } from '../interceptors' +import { MutationArgsType, UpdateOneInputType } from '../types' +import { FindOneArgsType } from '../types/find-one-args.type' +import { BaseServiceResolver, MutationOpts, ResolverClass, ServiceResolver } from './resolver.interface' + +export interface UpdateResolverOpts> extends MutationOpts { + UpdateDTOClass?: Class + UpdateOneInput?: Class> +} + +export interface UpdateResolver> extends ServiceResolver { + updateOne(id: FindOneArgsType, input: MutationArgsType>, authFilter?: Filter): Promise +} + +/** @internal */ +const defaultUpdateDTO = (dtoNames: DTONames, DTOClass: Class): Class => { + @ApiSchema({ name: `Update${dtoNames.baseName}` }) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + class DefaultUpdateDTO extends PartialType(DTOClass) {} + + return DefaultUpdateDTO as Class +} + +const defaultUpdateOneInput = (dtoNames: DTONames, UpdateDTO: Class): Class> => { + return UpdateOneInputType(UpdateDTO) +} + +/** + * @internal + * Mixin to add `update` graphql endpoints. + */ +export const Updateable = + >(DTOClass: Class, opts: UpdateResolverOpts) => + >>(BaseClass: B): Class> & B => { + if (opts.disabled) { + return BaseClass as never + } + + const dtoNames = getDTONames(DTOClass, opts) + + const { + UpdateDTOClass = defaultUpdateDTO(dtoNames, DTOClass), + UpdateOneInput = defaultUpdateOneInput(dtoNames, UpdateDTOClass) + } = opts + + const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'UpdateDTOClass', 'UpdateOneInput', 'UpdateManyInput') + + class UOI extends MutationArgsType(UpdateOneInput) {} + + @ApiSchema({ name: `FindUpdate${DTOClass.name}Args` }) + class UOP extends FindOneArgsType(DTOClass) {} + + class UpdateResolverBase extends BaseClass { + @Put( + () => DTOClass, + { + path: opts?.one?.path ?? ':id', + operation: { + operationId: `${dtoNames.pluralBaseNameLower}.updateOne`, + tags: [...(opts.tags || []), ...(opts.one?.tags ?? [])], + description: opts?.one?.description, + ...opts?.one?.operationOptions + }, + body: { + type: UpdateDTOClass + } + }, + { + interceptors: [HookInterceptor(HookTypes.BEFORE_UPDATE_ONE, UpdateDTOClass, DTOClass), AuthorizerInterceptor(DTOClass)] + }, + commonResolverOpts, + opts?.one ?? {} + ) + public updateOne( + @Param() params: UOP, + @BodyHookArgs() { input }: UOI, + @AuthorizerFilter({ + operationGroup: OperationGroup.UPDATE, + many: false + }) + authorizeFilter?: Filter + ): Promise { + return this.service.updateOne(params.id, input.update, { filter: authorizeFilter ?? {} }) + } + } + + return UpdateResolverBase + } + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const UpdateResolver = < + DTO, + U = DeepPartial, + QS extends QueryService = QueryService +>( + DTOClass: Class, + opts: UpdateResolverOpts = {} +): ResolverClass> => Updateable(DTOClass, opts)(BaseServiceResolver) diff --git a/packages/query-rest/src/types/create-one-input.type.ts b/packages/query-rest/src/types/create-one-input.type.ts new file mode 100644 index 000000000..cc6d914a0 --- /dev/null +++ b/packages/query-rest/src/types/create-one-input.type.ts @@ -0,0 +1,24 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +export interface CreateOneInputType { + input: C +} + +/** + * The abstract input type for create one operations. + * + * @param fieldName - The name of the field to be exposed in the graphql schema + * @param InputClass - The InputType to be used. + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export function CreateOneInputType(InputClass: Class): Class> { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + class CreateOneInput extends InputClass implements CreateOneInputType { + public get input() { + return this as never as C + } + } + + return CreateOneInput +} diff --git a/packages/query-rest/src/types/find-one-args.type.ts b/packages/query-rest/src/types/find-one-args.type.ts new file mode 100644 index 000000000..09e7f67ae --- /dev/null +++ b/packages/query-rest/src/types/find-one-args.type.ts @@ -0,0 +1,22 @@ +import { PickType } from '@nestjs/swagger' +import { Class } from '@ptc-org/nestjs-query-core' + +import { getIDField } from '../decorators' + +export interface FindOneArgsType { + id: string | number +} + +/** + * The input type for "one" endpoints. + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export function FindOneArgsType(DTOClass: Class): Class { + const dtoWithIDField = getIDField(DTOClass) + + class FindOneArgs extends PickType(DTOClass, [dtoWithIDField.propertyName] as never) implements FindOneArgsType { + id: string | number + } + + return FindOneArgs +} diff --git a/packages/query-rest/src/types/index.ts b/packages/query-rest/src/types/index.ts new file mode 100644 index 000000000..b88f04daf --- /dev/null +++ b/packages/query-rest/src/types/index.ts @@ -0,0 +1,6 @@ +export * from './create-one-input.type' +export * from './mutation-args.type' +export * from './query' +export * from './query-args.type' +export * from './rest-query.type' +export * from './update-one-input.type' diff --git a/packages/query-rest/src/types/mutation-args.type.ts b/packages/query-rest/src/types/mutation-args.type.ts new file mode 100644 index 000000000..90f8a7ce2 --- /dev/null +++ b/packages/query-rest/src/types/mutation-args.type.ts @@ -0,0 +1,17 @@ +import { Type } from '@nestjs/common' +import { Class } from '@ptc-org/nestjs-query-core' + +export interface MutationArgsType { + input: Input +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export function MutationArgsType(InputClass: Class): Class> { + class MutationArgs extends (InputClass as Type) implements MutationArgsType { + public get input() { + return this as never as Input + } + } + + return MutationArgs +} diff --git a/packages/query-rest/src/types/query-args.type.ts b/packages/query-rest/src/types/query-args.type.ts new file mode 100644 index 000000000..365d204bb --- /dev/null +++ b/packages/query-rest/src/types/query-args.type.ts @@ -0,0 +1,50 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +import { removeUndefinedValues } from '../common' +import { getQueryOptions } from '../decorators' +import { + DEFAULT_QUERY_OPTS, + NonePagingQueryArgsTypeOpts, + OffsetQueryArgsTypeOpts, + PagingStrategies, + QueryArgsTypeOpts, + StaticQueryType +} from './query' +import { createOffsetQueryArgs } from './query/query-args/offset-query-args.type' + +const getMergedQueryOpts = (DTOClass: Class, opts?: QueryArgsTypeOpts): QueryArgsTypeOpts => { + const decoratorOpts = getQueryOptions(DTOClass) + return { + ...DEFAULT_QUERY_OPTS, + pagingStrategy: PagingStrategies.OFFSET, + ...removeUndefinedValues(decoratorOpts ?? {}), + ...removeUndefinedValues(opts ?? {}) + } +} + +// tests if the object is a QueryArgs Class +// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types +export const isStaticQueryArgsType = (obj: any): obj is StaticQueryType => + typeof obj === 'function' && 'PageType' in obj && 'SortType' in obj && 'FilterType' in obj + +export function QueryArgsType( + DTOClass: Class, + opts: OffsetQueryArgsTypeOpts +): StaticQueryType +export function QueryArgsType( + DTOClass: Class, + opts: NonePagingQueryArgsTypeOpts +): StaticQueryType + +export function QueryArgsType(DTOClass: Class, opts?: QueryArgsTypeOpts): StaticQueryType +export function QueryArgsType(DTOClass: Class, opts?: QueryArgsTypeOpts): StaticQueryType { + // override any options from the DTO with the options passed in + const mergedOpts = getMergedQueryOpts(DTOClass, opts) + if (mergedOpts.pagingStrategy === PagingStrategies.OFFSET) { + return createOffsetQueryArgs(DTOClass, mergedOpts) + } + + // TODO:: Support none paging type + return createOffsetQueryArgs(DTOClass, mergedOpts as never) + // return createNonePagingQueryArgsType(DTOClass, mergedOpts) +} diff --git a/packages/query-rest/src/types/query/buildable-query.type.ts b/packages/query-rest/src/types/query/buildable-query.type.ts new file mode 100644 index 000000000..f8a923a69 --- /dev/null +++ b/packages/query-rest/src/types/query/buildable-query.type.ts @@ -0,0 +1,5 @@ +import { RestQuery } from '../rest-query.type' + +export interface BuildableQueryType { + buildQuery(): RestQuery +} diff --git a/packages/query-rest/src/types/query/filter.type.ts b/packages/query-rest/src/types/query/filter.type.ts new file mode 100644 index 000000000..c47c2677d --- /dev/null +++ b/packages/query-rest/src/types/query/filter.type.ts @@ -0,0 +1,106 @@ +import { applyDecorators } from '@nestjs/common' +import { Class, Filter, MapReflector } from '@ptc-org/nestjs-query-core' + +import { Field, filterableFieldOptionsToField, getFilterableFields } from '../../decorators' + +const reflector = new MapReflector('nestjs-query:filter-type') +// internal cache is used to exit early if the same filter is requested multiple times +// e.g. if there is a circular reference in the relations +// `User -> Post -> User-> Post -> ...` +const internalCache = new Map, Map>>() + +export interface FilterConstructor { + hasRequiredFilters: boolean + + new (): Filter +} + +function getOrCreateFilterType( + TClass: Class, + prefix: string | null, + suffix: string | null, + BaseClass: Class +): FilterConstructor { + const $prefix = prefix ?? '' + const $suffix = suffix ?? '' + + const name = `${$prefix}${TClass.name}${$suffix}` + const typeName = `${name}Filter` + + return reflector.memoize(TClass, typeName, () => { + const fields = getFilterableFields(TClass) + + // if the filter is already in the cache, exist early and return it + // otherwise add it to the cache early so we don't get into an infinite loop + let TClassCache = internalCache.get(TClass) + + if (TClassCache && TClassCache.has(typeName)) { + return TClassCache.get(typeName) as FilterConstructor + } + + const hasRequiredFilters = fields.some((f) => f.advancedOptions?.filterRequired === true) + + class QueryFilter extends BaseClass { + static hasRequiredFilters: boolean = hasRequiredFilters + + public get filter(): Filter { + const filters = fields.reduce((filter, field) => { + if (this[field.schemaName]) { + filter[field.schemaName] = { eq: this[field.schemaName] } + } + + return filter + }, {} as Filter) + + if (Object.keys(filters).length > 0) { + return filters + } + + return super.filter + } + } + + fields.forEach(({ schemaName, advancedOptions }) => { + applyDecorators( + Field( + filterableFieldOptionsToField({ + ...advancedOptions, + nullable: + typeof advancedOptions.filterRequired !== 'undefined' ? !advancedOptions.filterRequired : advancedOptions.nullable, + required: Boolean( + typeof advancedOptions.filterRequired !== 'undefined' ? advancedOptions.filterRequired : advancedOptions.required + ) + }) + ), + ...(advancedOptions.filterDecorators || []) + )(QueryFilter.prototype, schemaName) + }) + + TClassCache = TClassCache ?? new Map() + + TClassCache.set(typeName, QueryFilter) + internalCache.set(TClass, TClassCache) + + return QueryFilter as never as FilterConstructor + }) +} + +export function FilterType(TClass: Class, BaseClass: Class): FilterConstructor { + return getOrCreateFilterType(TClass, null, null, BaseClass) +} + +// export function DeleteFilterType(TClass: Class, BaseClass: Class): FilterConstructor { +// return getOrCreateFilterType(TClass, null, 'Delete', BaseClass) +// } +// +// export function UpdateFilterType(TClass: Class, BaseClass: Class): FilterConstructor { +// return getOrCreateFilterType(TClass, null, 'Update', BaseClass) +// } +// +// export function SubscriptionFilterType(TClass: Class, BaseClass: Class): FilterConstructor { +// return getOrCreateFilterType(TClass, null, 'Subscription', BaseClass) +// } +// +// export function AggregateFilterType(TClass: Class, BaseClass: Class): FilterConstructor { +// return getOrCreateFilterType(TClass, null, 'Aggregate', BaseClass) +// } diff --git a/packages/query-rest/src/types/query/index.ts b/packages/query-rest/src/types/query/index.ts new file mode 100644 index 000000000..6afae4c17 --- /dev/null +++ b/packages/query-rest/src/types/query/index.ts @@ -0,0 +1,3 @@ +export * from './offset-paging.type' +export * from './paging' +export * from './query-args' diff --git a/packages/query-rest/src/types/query/offset-paging.type.ts b/packages/query-rest/src/types/query/offset-paging.type.ts new file mode 100644 index 000000000..73812ee6e --- /dev/null +++ b/packages/query-rest/src/types/query/offset-paging.type.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Paging } from '@ptc-org/nestjs-query-core' +import { Expose, Type } from 'class-transformer' +import { IsNumber, IsOptional, Max, Min } from 'class-validator' + +export class OffsetPaging implements Paging { + @Expose() + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(1) + @Max(50) + @ApiProperty({ + nullable: true, + required: false + }) + limit?: number + + @Expose() + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiProperty({ + nullable: true, + required: false + }) + offset?: number +} diff --git a/packages/query-rest/src/types/query/paging/constants.ts b/packages/query-rest/src/types/query/paging/constants.ts new file mode 100644 index 000000000..4994dbdee --- /dev/null +++ b/packages/query-rest/src/types/query/paging/constants.ts @@ -0,0 +1,5 @@ +export enum PagingStrategies { + // CURSOR = 'cursor', + OFFSET = 'offset', + NONE = 'none' +} diff --git a/packages/query-rest/src/types/query/paging/index.ts b/packages/query-rest/src/types/query/paging/index.ts new file mode 100644 index 000000000..8a3d08dba --- /dev/null +++ b/packages/query-rest/src/types/query/paging/index.ts @@ -0,0 +1,3 @@ +export { PagingStrategies } from './constants' +export { InferPagingTypeFromStrategy, NonePagingType, OffsetPagingType, PagingTypes } from './interfaces' +export { getOrCreateNonePagingType } from './none-paging.type' diff --git a/packages/query-rest/src/types/query/paging/interfaces.ts b/packages/query-rest/src/types/query/paging/interfaces.ts new file mode 100644 index 000000000..1f6a6c9ee --- /dev/null +++ b/packages/query-rest/src/types/query/paging/interfaces.ts @@ -0,0 +1,14 @@ +import { Paging } from '@ptc-org/nestjs-query-core' + +import { PagingStrategies } from './constants' + +export type NonePagingType = Paging +export type OffsetPagingType = Paging + +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type PagingTypes = OffsetPagingType | NonePagingType +export type InferPagingTypeFromStrategy = PS extends PagingStrategies.OFFSET + ? OffsetPagingType + : PS extends PagingStrategies.NONE + ? NonePagingType + : never diff --git a/packages/query-rest/src/types/query/paging/none-paging.type.ts b/packages/query-rest/src/types/query/paging/none-paging.type.ts new file mode 100644 index 000000000..2b0ba988e --- /dev/null +++ b/packages/query-rest/src/types/query/paging/none-paging.type.ts @@ -0,0 +1,19 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +import { PagingStrategies } from './constants' +import { NonePagingType } from './interfaces' + +let graphQLPaging: Class | null = null +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export const getOrCreateNonePagingType = (): Class => { + if (graphQLPaging) { + return graphQLPaging + } + + class GraphQLPagingImpl implements NonePagingType { + static strategy: PagingStrategies.NONE = PagingStrategies.NONE + } + + graphQLPaging = GraphQLPagingImpl + return graphQLPaging +} diff --git a/packages/query-rest/src/types/query/query-args/constants.ts b/packages/query-rest/src/types/query/query-args/constants.ts new file mode 100644 index 000000000..0e91249ac --- /dev/null +++ b/packages/query-rest/src/types/query/query-args/constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_QUERY_OPTS = { + defaultResultSize: 10, + maxResultsSize: 50, + defaultSort: [], + defaultFilter: {} +} diff --git a/packages/query-rest/src/types/query/query-args/index.ts b/packages/query-rest/src/types/query/query-args/index.ts new file mode 100644 index 000000000..52cd7a516 --- /dev/null +++ b/packages/query-rest/src/types/query/query-args/index.ts @@ -0,0 +1,2 @@ +export * from './constants' +export * from './interfaces' diff --git a/packages/query-rest/src/types/query/query-args/interfaces.ts b/packages/query-rest/src/types/query/query-args/interfaces.ts new file mode 100644 index 000000000..1ae4634eb --- /dev/null +++ b/packages/query-rest/src/types/query/query-args/interfaces.ts @@ -0,0 +1,59 @@ +import { Class, Filter, Query, SortField } from '@ptc-org/nestjs-query-core' + +import { ArrayConnectionOptions, OffsetConnectionOptions, StaticConnectionType } from '../../../connection/interfaces' +import { InferPagingTypeFromStrategy, PagingStrategies } from '../paging' + +export type BaseQueryArgsTypeOpts = { + /** + * Support the `query=term` query param which can be used inside the before query many + * to build an filter + */ + enableSearch?: boolean + /** + * The default number of results to return. + * [Default=10] + */ + defaultResultSize?: number + /** + * The maximum number of results that can be returned from a query. + * [Default=50] + */ + maxResultsSize?: number + /** + * The default sort for queries. + * [Default=[]] + */ + defaultSort?: SortField[] + /** + * Disable the sorting + */ + disableSort?: boolean + /** + * Default filter. + * [Default=\{\}] + */ + defaultFilter?: Filter + /** + * Disable the filtering + */ + disableFilter?: boolean +} + +export interface OffsetQueryArgsTypeOpts extends BaseQueryArgsTypeOpts, OffsetConnectionOptions { + pagingStrategy?: PagingStrategies.OFFSET +} + +export interface NonePagingQueryArgsTypeOpts extends BaseQueryArgsTypeOpts, ArrayConnectionOptions { + pagingStrategy?: PagingStrategies.NONE +} + +export type QueryArgsTypeOpts = OffsetQueryArgsTypeOpts | NonePagingQueryArgsTypeOpts + +export interface StaticQueryType extends Class> { + FilterType: Class> + ConnectionType: StaticConnectionType +} + +export interface QueryType extends Query { + paging?: InferPagingTypeFromStrategy +} diff --git a/packages/query-rest/src/types/query/query-args/offset-query-args.type.ts b/packages/query-rest/src/types/query/query-args/offset-query-args.type.ts new file mode 100644 index 000000000..fea2c3ef2 --- /dev/null +++ b/packages/query-rest/src/types/query/query-args/offset-query-args.type.ts @@ -0,0 +1,54 @@ +import { Class } from '@ptc-org/nestjs-query-core' +import { RestQuery } from '@ptc-org/nestjs-query-rest' + +import { getOrCreateOffsetConnectionType } from '../../../connection/offset/offset-connection.type' +import { Field, SkipIf } from '../../../decorators' +import { BuildableQueryType } from '../buildable-query.type' +import { FilterType } from '../filter.type' +import { OffsetPaging } from '../offset-paging.type' +import { PagingStrategies } from '../paging' +import { DEFAULT_QUERY_OPTS } from './constants' +import { OffsetQueryArgsTypeOpts, StaticQueryType } from './interfaces' + +export function createOffsetQueryArgs( + DTOClass: Class, + opts: OffsetQueryArgsTypeOpts = { ...DEFAULT_QUERY_OPTS, pagingStrategy: PagingStrategies.OFFSET } +): StaticQueryType { + // const S = getOrCreateSortType(DTOClass) + + const ConnectionType = getOrCreateOffsetConnectionType(DTOClass, opts) + + class QueryArgs extends OffsetPaging implements BuildableQueryType { + static ConnectionType = ConnectionType + + public sorting = opts.defaultSort + + public get filter() { + return opts.defaultFilter + } + + @SkipIf( + () => !opts.enableSearch, + Field({ + nullable: true, + required: false + }) + ) + public query?: string + + public buildQuery(): RestQuery { + return { + query: this.query, + paging: { + limit: this.limit || opts.maxResultsSize, + offset: this.offset + }, + filter: this.filter, + sorting: this.sorting, + relations: [] + } + } + } + + return FilterType(DTOClass, QueryArgs) as never as StaticQueryType +} diff --git a/packages/query-rest/src/types/rest-query.type.ts b/packages/query-rest/src/types/rest-query.type.ts new file mode 100644 index 000000000..d6d616fd5 --- /dev/null +++ b/packages/query-rest/src/types/rest-query.type.ts @@ -0,0 +1,5 @@ +import { Query } from '@ptc-org/nestjs-query-core' + +export interface RestQuery extends Query { + query?: string +} diff --git a/packages/query-rest/src/types/update-one-input.type.ts b/packages/query-rest/src/types/update-one-input.type.ts new file mode 100644 index 000000000..cc675fa46 --- /dev/null +++ b/packages/query-rest/src/types/update-one-input.type.ts @@ -0,0 +1,24 @@ +import { Class } from '@ptc-org/nestjs-query-core' + +export interface UpdateOneInputType { + update: U +} + +/** + * The abstract input type for create one operations. + * + * @param fieldName - The name of the field to be exposed in the graphql schema + * @param UpdateClass - The InputType to be used. + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- intentional +export function UpdateOneInputType(UpdateClass: Class): Class> { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + class UpdateOneInput extends UpdateClass implements UpdateOneInputType { + public get update() { + return this as never as U + } + } + + return UpdateOneInput +} diff --git a/packages/query-rest/src/types/validators/index.ts b/packages/query-rest/src/types/validators/index.ts new file mode 100644 index 000000000..3a1aa92fc --- /dev/null +++ b/packages/query-rest/src/types/validators/index.ts @@ -0,0 +1 @@ +export * from './is-undefined.validator' diff --git a/packages/query-rest/src/types/validators/is-undefined.validator.ts b/packages/query-rest/src/types/validators/is-undefined.validator.ts new file mode 100644 index 000000000..ec33663f3 --- /dev/null +++ b/packages/query-rest/src/types/validators/is-undefined.validator.ts @@ -0,0 +1,8 @@ +import { ValidateIf, ValidationOptions } from 'class-validator' + +/** @internal */ +export function IsUndefined(validationOptions?: ValidationOptions) { + // eslint-disable-next-line @typescript-eslint/ban-types + return (obj: Object, property: string) => + ValidateIf((o: Record) => o[property] !== undefined, validationOptions)(obj, property) +} diff --git a/packages/query-rest/tsconfig.json b/packages/query-rest/tsconfig.json new file mode 100644 index 000000000..546e940e2 --- /dev/null +++ b/packages/query-rest/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/query-rest/tsconfig.lib.json b/packages/query-rest/tsconfig.lib.json new file mode 100644 index 000000000..84752388e --- /dev/null +++ b/packages/query-rest/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [ + "node" + ] + }, + "exclude": [ + "jest.config.ts", + "**/*.spec.ts", + "**/*.test.ts", + "__tests__" + ], + "include": [ + "**/*.ts" + ] +} diff --git a/packages/query-rest/tsconfig.spec.json b/packages/query-rest/tsconfig.spec.json new file mode 100644 index 000000000..ee46e96db --- /dev/null +++ b/packages/query-rest/tsconfig.spec.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "jest.config.ts", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index a32e59990..b7c1202f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,8 @@ "@ptc-org/nestjs-query-mongoose": ["packages/query-mongoose/src/index.ts"], "@ptc-org/nestjs-query-sequelize": ["packages/query-sequelize/src/index.ts"], "@ptc-org/nestjs-query-typegoose": ["packages/query-typegoose/src/index.ts"], - "@ptc-org/nestjs-query-typeorm": ["packages/query-typeorm/src/index.ts"] + "@ptc-org/nestjs-query-typeorm": ["packages/query-typeorm/src/index.ts"], + "@ptc-org/nestjs-query-rest": ["packages/query-rest/src/index.ts"] } } } diff --git a/yarn.lock b/yarn.lock index 8d3b36335..fb57e44c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5313,6 +5313,13 @@ __metadata: languageName: node linkType: hard +"@microsoft/tsdoc@npm:^0.15.0": + version: 0.15.0 + resolution: "@microsoft/tsdoc@npm:0.15.0" + checksum: 10/fd025e5e3966248cd5477b9ddad4e9aa0dd69291f372a207f18a686b3097dcf5ecf38325caf0f4ad2697f1f39fd45b536e4ada6756008b8bcc5eccbc3201313d + languageName: node + linkType: hard + "@mongodb-js/saslprep@npm:^1.1.0": version: 1.1.0 resolution: "@mongodb-js/saslprep@npm:1.1.0" @@ -5598,6 +5605,34 @@ __metadata: languageName: node linkType: hard +"@nestjs/swagger@npm:^7.1.15": + version: 7.4.2 + resolution: "@nestjs/swagger@npm:7.4.2" + dependencies: + "@microsoft/tsdoc": "npm:^0.15.0" + "@nestjs/mapped-types": "npm:2.0.5" + js-yaml: "npm:4.1.0" + lodash: "npm:4.17.21" + path-to-regexp: "npm:3.3.0" + swagger-ui-dist: "npm:5.17.14" + peerDependencies: + "@fastify/static": ^6.0.0 || ^7.0.0 + "@nestjs/common": ^9.0.0 || ^10.0.0 + "@nestjs/core": ^9.0.0 || ^10.0.0 + class-transformer: "*" + class-validator: "*" + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + "@fastify/static": + optional: true + class-transformer: + optional: true + class-validator: + optional: true + checksum: 10/e3f9cac6a092442461fe7e4edd45b8af3377a02c626bb2b9f7da2e0ffe4999c3c20f32240aa1a67e9999a919946e6ed6c0da01c703e7d83d49823f1d7b9caf09 + languageName: node + linkType: hard + "@nestjs/testing@npm:^10.4.1": version: 10.4.1 resolution: "@nestjs/testing@npm:10.4.1" @@ -6214,6 +6249,26 @@ __metadata: languageName: unknown linkType: soft +"@ptc-org/nestjs-query-rest@workspace:packages/query-rest": + version: 0.0.0-use.local + resolution: "@ptc-org/nestjs-query-rest@workspace:packages/query-rest" + dependencies: + lodash.omit: "npm:^4.5.0" + lower-case-first: "npm:^2.0.2" + pluralize: "npm:^8.0.0" + tslib: "npm:^2.6.2" + upper-case-first: "npm:^2.0.2" + peerDependencies: + "@nestjs/common": ^9.0.0 || ^10.0.0 + "@nestjs/core": ^9.0.0 || ^10.0.0 + "@nestjs/graphql": ^11.0.0 || ^12.0.0 + "@nestjs/swagger": ^7.0.0 + class-transformer: ^0.5 + class-validator: ^0.14.0 + ts-morph: ^19.0.0 + languageName: unknown + linkType: soft + "@ptc-org/nestjs-query-sequelize@workspace:packages/query-sequelize": version: 0.0.0-use.local resolution: "@ptc-org/nestjs-query-sequelize@workspace:packages/query-sequelize" @@ -18055,6 +18110,7 @@ __metadata: "@nestjs/platform-express": "npm:10.4.1" "@nestjs/schematics": "npm:10.1.4" "@nestjs/sequelize": "npm:10.0.1" + "@nestjs/swagger": "npm:^7.1.15" "@nestjs/testing": "npm:^10.4.1" "@nestjs/typeorm": "npm:^10.0.2" "@nx-extend/docusaurus": "npm:^2.0.1" @@ -19096,6 +19152,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:3.3.0": + version: 3.3.0 + resolution: "path-to-regexp@npm:3.3.0" + checksum: 10/8d256383af8db66233ee9027cfcbf8f5a68155efbb4f55e784279d3ab206dcaee554ddb72ff0dae97dd2882af9f7fa802634bb7cffa2e796927977e31b829259 + languageName: node + linkType: hard + "path-to-regexp@npm:^1.7.0": version: 1.8.0 resolution: "path-to-regexp@npm:1.8.0" @@ -22186,6 +22249,13 @@ __metadata: languageName: node linkType: hard +"swagger-ui-dist@npm:5.17.14": + version: 5.17.14 + resolution: "swagger-ui-dist@npm:5.17.14" + checksum: 10/b9e62d7ecb64e837849252c9f82af654b26cae60ebd551cff96495d826166d3ed866ebae40f22a2c61d307330151945d79d995e50659ae17eea6cf4ece788f9d + languageName: node + linkType: hard + "symbol-observable@npm:4.0.0, symbol-observable@npm:^4.0.0": version: 4.0.0 resolution: "symbol-observable@npm:4.0.0"