Skip to content

Allow using auth filters on creation #321

New issue

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

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

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ describe('AssemblerQueryService', () => {
it('should transform the results for a single entity', () => {
const mockQueryService = mock<QueryService<TestEntity>>()
const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService))
when(mockQueryService.createOne(objectContaining({ bar: 'baz' }))).thenResolve({
when(mockQueryService.createOne(objectContaining({ bar: 'baz' }), undefined)).thenResolve({
bar: 'baz'
})

Expand All @@ -457,7 +457,7 @@ describe('AssemblerQueryService', () => {
it('should transform the results for a single entity', () => {
const mockQueryService = mock<QueryService<TestEntity>>()
const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService))
when(mockQueryService.createMany(deepEqual([{ bar: 'baz' }]))).thenResolve([{ bar: 'baz' }])
when(mockQueryService.createMany(deepEqual([{ bar: 'baz' }]), undefined)).thenResolve([{ bar: 'baz' }])

return expect(assemblerService.createMany([{ foo: 'baz' }])).resolves.toEqual([{ foo: 'baz' }])
})
Expand Down
4 changes: 2 additions & 2 deletions packages/core/__tests__/services/proxy-query.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ describe('ProxyQueryService', () => {
})
it('should proxy to the underlying service when calling createMany', () => {
const entities = [{ foo: 'bar' }]
when(mockQueryService.createMany(entities)).thenResolve(entities)
when(mockQueryService.createMany(entities, undefined)).thenResolve(entities)
return expect(queryService.createMany(entities)).resolves.toBe(entities)
})
it('should proxy to the underlying service when calling createOne', () => {
const entity = { foo: 'bar' }
when(mockQueryService.createOne(entity)).thenResolve(entity)
when(mockQueryService.createOne(entity, undefined)).thenResolve(entity)
return expect(queryService.createOne(entity)).resolves.toBe(entity)
})
it('should proxy to the underlying service when calling deleteMany', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/interfaces/create-many-options.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CreateOneOptions } from './create-one-options.interface'

export type CreateManyOptions<DTO> = CreateOneOptions<DTO>
9 changes: 9 additions & 0 deletions packages/core/src/interfaces/create-one-options.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Filter } from './filter.interface'

export interface CreateOneOptions<DTO> {
/**
* Additional filter applied to the input dto before creation. This could be used to apply an additional filter to ensure
* that the entity being created belongs to a particular user.
*/
filter?: Filter<DTO>
}
2 changes: 2 additions & 0 deletions packages/core/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export * from './aggregate-options.interface'
export * from './aggregate-query.interface'
export * from './aggregate-response.interface'
export * from './count-options.interface'
export * from './create-many-options.interface'
export * from './create-one-options.interface'
export * from './delete-many-options.interface'
export * from './delete-many-response.interface'
export * from './delete-one-options.interface'
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/services/assembler-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
AggregateQuery,
AggregateResponse,
CountOptions,
CreateManyOptions,
CreateOneOptions,
DeleteManyResponse,
DeleteOneOptions,
Filter,
Expand Down Expand Up @@ -39,15 +41,15 @@ export class AssemblerQueryService<DTO, Entity, C = DeepPartial<DTO>, CE = DeepP
)
}

public async createMany(items: C[]): Promise<DTO[]> {
public async createMany(items: C[], opts?: CreateManyOptions<DTO>): Promise<DTO[]> {
const { assembler } = this
const converted = await assembler.convertToCreateEntities(items)
return this.assembler.convertToDTOs(await this.queryService.createMany(converted))
return this.assembler.convertToDTOs(await this.queryService.createMany(converted, this.convertFilterable(opts)))
}

public async createOne(item: C): Promise<DTO> {
public async createOne(item: C, opts?: CreateOneOptions<DTO>): Promise<DTO> {
const c = await this.assembler.convertToCreateEntity(item)
return this.assembler.convertToDTO(await this.queryService.createOne(c))
return this.assembler.convertToDTO(await this.queryService.createOne(c, this.convertFilterable(opts)))
}

public async deleteMany(filter: Filter<DTO>): Promise<DeleteManyResponse> {
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/services/noop-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
AggregateQuery,
AggregateResponse,
CountOptions,
CreateManyOptions,
CreateOneOptions,
DeleteManyOptions,
DeleteManyResponse,
DeleteOneOptions,
Expand Down Expand Up @@ -43,11 +45,11 @@ export class NoOpQueryService<DTO, C = DeepPartial<DTO>, U = DeepPartial<DTO>> i
return Promise.reject(new NotImplementedException('addRelations is not implemented'))
}

public createMany(items: C[]): Promise<DTO[]> {
public createMany(items: C[], opts?: CreateManyOptions<DTO>): Promise<DTO[]> {
return Promise.reject(new NotImplementedException('createMany is not implemented'))
}

public createOne(item: C): Promise<DTO> {
public createOne(item: C, opts?: CreateOneOptions<DTO>): Promise<DTO> {
return Promise.reject(new NotImplementedException('createOne is not implemented'))
}

Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/services/proxy-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
AggregateQuery,
AggregateResponse,
CountOptions,
CreateManyOptions,
CreateOneOptions,
DeleteManyResponse,
DeleteOneOptions,
Filter,
Expand Down Expand Up @@ -172,12 +174,12 @@ export class ProxyQueryService<DTO, C = DeepPartial<DTO>, U = DeepPartial<DTO>>
return this.proxied.findRelation(RelationClass, relationName, dto, opts)
}

public createMany(items: C[]): Promise<DTO[]> {
return this.proxied.createMany(items)
public createMany(items: C[], opts?: CreateManyOptions<DTO>): Promise<DTO[]> {
return this.proxied.createMany(items, opts)
}

public createOne(item: C): Promise<DTO> {
return this.proxied.createOne(item)
public createOne(item: C, opts?: CreateOneOptions<DTO>): Promise<DTO> {
return this.proxied.createOne(item, opts)
}

public async deleteMany(filter: Filter<DTO>): Promise<DeleteManyResponse> {
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/services/query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
AggregateQuery,
AggregateResponse,
CountOptions,
CreateManyOptions,
CreateOneOptions,
DeleteManyOptions,
DeleteManyResponse,
DeleteOneOptions,
Expand Down Expand Up @@ -243,17 +245,19 @@ export interface QueryService<DTO, C = DeepPartial<DTO>, U = DeepPartial<DTO>> {
* Create a single record.
*
* @param item - the record to create.
* @param opts - Additional opts to apply when creating one entity.
* @returns the created record.
*/
createOne(item: C): Promise<DTO>
createOne(item: C, opts?: CreateOneOptions<DTO>): Promise<DTO>

/**
* Creates a multiple record.
*
* @param items - the records to create.
* @param opts - Additional opts to apply when creating many entities.
* @returns a created records.
*/
createMany(items: C[]): Promise<DTO[]>
createMany(items: C[], opts?: CreateManyOptions<DTO>): Promise<DTO[]>

/**
* Update one record.
Expand Down
20 changes: 10 additions & 10 deletions packages/query-graphql/__tests__/resolvers/create.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('CreateResolver', () => {
id: 'id-1',
stringField: 'foo'
}
when(mockService.createOne(objectContaining(args.input))).thenResolve(output)
when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createOne({ input: args })
return expect(result).toEqual(output)
})
Expand Down Expand Up @@ -109,7 +109,7 @@ describe('CreateResolver', () => {
stringField: 'foo'
}
]
when(mockService.createMany(objectContaining(args.input))).thenResolve(output)
when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createMany({ input: args })
return expect(result).toEqual(output)
})
Expand All @@ -134,7 +134,7 @@ describe('CreateResolver', () => {
}
const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO)
const event = { [eventName]: output }
when(mockService.createOne(objectContaining(args.input))).thenResolve(output)
when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output)
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve()
const result = await resolver.createOne({ input: args })
verify(mockPubSub.publish(eventName, deepEqual(event))).once()
Expand All @@ -154,7 +154,7 @@ describe('CreateResolver', () => {
}
const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO)
const event = { [eventName]: output }
when(mockService.createOne(objectContaining(args.input))).thenResolve(output)
when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output)
when(mockPubSub.publish(eventName, deepEqual(event))).thenResolve()
const result = await resolver.createOne({ input: args })
verify(mockPubSub.publish(eventName, deepEqual(event))).once()
Expand All @@ -172,7 +172,7 @@ describe('CreateResolver', () => {
id: 'id-1',
stringField: 'foo'
}
when(mockService.createOne(objectContaining(args.input))).thenResolve(output)
when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createOne({ input: args })
verify(mockPubSub.publish(anything(), anything())).never()
return expect(result).toEqual(output)
Expand All @@ -192,7 +192,7 @@ describe('CreateResolver', () => {
id: 'id-1',
stringField: 'foo'
}
when(mockService.createOne(objectContaining(args.input))).thenResolve(output)
when(mockService.createOne(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createOne({ input: args })
verify(mockPubSub.publish(anything(), anything())).never()
return expect(result).toEqual(output)
Expand All @@ -217,7 +217,7 @@ describe('CreateResolver', () => {
]
const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO)
const events = output.map((o) => ({ [eventName]: o }))
when(mockService.createMany(objectContaining(args.input))).thenResolve(output)
when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output)
events.forEach((e) => when(mockPubSub.publish(eventName, deepEqual(e))).thenResolve())
const result = await resolver.createMany({ input: args })
events.forEach((e) => verify(mockPubSub.publish(eventName, deepEqual(e))).once())
Expand All @@ -241,7 +241,7 @@ describe('CreateResolver', () => {
]
const eventName = getDTOEventName(EventType.CREATED, TestResolverDTO)
const events = output.map((o) => ({ [eventName]: o }))
when(mockService.createMany(objectContaining(args.input))).thenResolve(output)
when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output)
events.forEach((e) => when(mockPubSub.publish(eventName, deepEqual(e))).thenResolve())
const result = await resolver.createMany({ input: args })
events.forEach((e) => verify(mockPubSub.publish(eventName, deepEqual(e))).once())
Expand All @@ -263,7 +263,7 @@ describe('CreateResolver', () => {
stringField: 'foo'
}
]
when(mockService.createMany(objectContaining(args.input))).thenResolve(output)
when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createMany({ input: args })
verify(mockPubSub.publish(anything(), anything())).never()
return expect(result).toEqual(output)
Expand All @@ -287,7 +287,7 @@ describe('CreateResolver', () => {
stringField: 'foo'
}
]
when(mockService.createMany(objectContaining(args.input))).thenResolve(output)
when(mockService.createMany(objectContaining(args.input), undefined)).thenResolve(output)
const result = await resolver.createMany({ input: args })
verify(mockPubSub.publish(anything(), anything())).never()
return expect(result).toEqual(output)
Expand Down
26 changes: 19 additions & 7 deletions packages/query-graphql/src/resolvers/create.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ import { BaseServiceResolver, ResolverClass, ServiceResolver, SubscriptionResolv

export type CreatedEvent<DTO> = { [eventName: string]: DTO }

export interface CreateResolverOpts<DTO, C = DeepPartial<DTO>> extends SubscriptionResolverOpts {
interface AuthValidationOpts {
/**
* Determines whether the auth filter should be passed into the query service
*/
validateWithAuthFilter?: boolean
}

export interface CreateResolverOpts<DTO, C = DeepPartial<DTO>> extends SubscriptionResolverOpts, AuthValidationOpts {
/**
* The Input DTO that should be used to create records.
*/
Expand All @@ -41,6 +48,9 @@ export interface CreateResolverOpts<DTO, C = DeepPartial<DTO>> extends Subscript

createOneMutationName?: string
createManyMutationName?: string

one?: SubscriptionResolverOpts['one'] & AuthValidationOpts
many?: SubscriptionResolverOpts['many'] & AuthValidationOpts
}

export interface CreateResolver<DTO, C, QS extends QueryService<DTO, C, unknown>> extends ServiceResolver<DTO, QS> {
Expand Down Expand Up @@ -134,11 +144,12 @@ export const Creatable =
@AuthorizerFilter({
operationGroup: OperationGroup.CREATE,
many: false
}) // eslint-disable-next-line @typescript-eslint/no-unused-vars
})
authorizeFilter?: Filter<DTO>
): Promise<DTO> {
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createOne(input.input.input)
const createOneOpts =
opts?.validateWithAuthFilter || opts?.one?.validateWithAuthFilter ? { filter: authorizeFilter ?? {} } : undefined
const created = await this.service.createOne(input.input.input, createOneOpts)
if (enableOneSubscriptions) {
await this.publishCreatedEvent(created, authorizeFilter)
}
Expand All @@ -159,11 +170,12 @@ export const Creatable =
@AuthorizerFilter({
operationGroup: OperationGroup.CREATE,
many: true
}) // eslint-disable-next-line @typescript-eslint/no-unused-vars
})
authorizeFilter?: Filter<DTO>
): Promise<DTO[]> {
// Ignore `authorizeFilter` for now but give users the ability to throw an UnauthorizedException
const created = await this.service.createMany(input.input.input)
const createManyOpts =
opts.validateWithAuthFilter || opts.many?.validateWithAuthFilter ? { filter: authorizeFilter ?? {} } : undefined
const created = await this.service.createMany(input.input.input, createManyOpts)
if (enableManySubscriptions) {
await Promise.all(created.map((c) => this.publishCreatedEvent(c, authorizeFilter)))
}
Expand Down
13 changes: 10 additions & 3 deletions packages/query-mongoose/src/services/mongoose-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { NotFoundException } from '@nestjs/common'
import {
AggregateQuery,
AggregateResponse,
applyFilter,
CreateOneOptions,
DeepPartial,
DeleteManyResponse,
DeleteOneOptions,
Expand Down Expand Up @@ -132,9 +134,13 @@ export class MongooseQueryService<Entity extends Document>
* const todoItem = await this.service.createOne({title: 'Todo Item', completed: false });
* ```
* @param record - The entity to create.
* @param opts - Additional options.
*/
async createOne(record: DeepPartial<Entity>): Promise<Entity> {
async createOne(record: DeepPartial<Entity>, opts?: CreateOneOptions<Entity>): Promise<Entity> {
this.ensureIdIsNotPresent(record)
if (opts?.filter && !applyFilter(record as Entity, opts.filter)) {
throw new Error('Entity does not meet creation constraints')
}
Comment on lines +141 to +143
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just doing some type casting here to make this happy but I think raises a good question. What should be the behaviour for a filter like createdAt > "last_year" when the createdAt is populated by the db?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something we could do, but that would make it all a lot more complex is by doing inside a TypeORM query runner, doing the insert, getting the record, apply filter (can even then be done with the normal builder), if failed revert and throw error.

Otherwise I think this would also need to be documented as a limitation. (Preferred)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's my preference too.

return this.Model.create(record)
}

Expand All @@ -149,10 +155,11 @@ export class MongooseQueryService<Entity extends Document>
* ]);
* ```
* @param records - The entities to create.
* @param opts - Additional options.
*/
public async createMany(records: DeepPartial<Entity>[]): Promise<Entity[]> {
public async createMany(records: DeepPartial<Entity>[], opts?: CreateOneOptions<Entity>): Promise<Entity[]> {
records.forEach((r) => this.ensureIdIsNotPresent(r))
return this.Model.create(records)
return this.Model.create(opts?.filter ? applyFilter(records as Entity[], opts.filter) : records)
}

/**
Expand Down
16 changes: 13 additions & 3 deletions packages/query-sequelize/src/services/sequelize-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common'
import {
AggregateQuery,
AggregateResponse,
applyFilter,
CreateOneOptions,
DeepPartial,
DeleteManyResponse,
DeleteOneOptions,
Expand Down Expand Up @@ -129,9 +131,13 @@ export class SequelizeQueryService<Entity extends Model<Entity, Partial<Entity>>
* const todoItem = await this.service.createOne({title: 'Todo Item', completed: false });
* ```
* @param record - The entity to create.
* @param opts - Additional options.
*/
public async createOne(record: DeepPartial<Entity>): Promise<Entity> {
public async createOne(record: DeepPartial<Entity>, opts?: CreateOneOptions<Entity>): Promise<Entity> {
await this.ensureEntityDoesNotExist(record)
if (opts?.filter && !applyFilter(record as Entity, opts.filter)) {
throw new Error('Entity does not meet creation constraints')
}
const changedValues = this.getChangedValues(record)
return this.model.create<Entity>(changedValues as MakeNullishOptional<Entity>)
}
Expand All @@ -147,11 +153,15 @@ export class SequelizeQueryService<Entity extends Model<Entity, Partial<Entity>>
* ]);
* ```
* @param records - The entities to create.
* @param opts - Additional options.
*/
public async createMany(records: DeepPartial<Entity>[]): Promise<Entity[]> {
public async createMany(records: DeepPartial<Entity>[], opts?: CreateOneOptions<Entity>): Promise<Entity[]> {
await Promise.all(records.map((r) => this.ensureEntityDoesNotExist(r)))
const filteredRecords = opts?.filter ? applyFilter(records as Entity[], opts.filter) : records

return this.model.bulkCreate<Entity>(records.map((r) => this.getChangedValues(r) as MakeNullishOptional<Entity>))
return this.model.bulkCreate<Entity>(
filteredRecords.map((r) => this.getChangedValues(r as DeepPartial<Entity>) as MakeNullishOptional<Entity>)
)
}

/**
Expand Down
Loading
Loading