@rxstack/platform
v0.8.0
Published
RxStack Platform Module
Downloads
101
Readme
The RxStack Platform Module
Rapid Application Development platform to build modern API-driven projects, built on top of rxstack framework.
Switch to RxStack Framework
Table of content
- Installation
- Documentation
- Models
- Services
- Operations
- Hooks
- Overwrite Operations
- Custom Operations
- Testing
- Add-ons
Installation
First you need to clone rxstack skeleton application.
git clone https://github.com/rxstack/skeleton.git my-project
cd my-project
npm install
After you cloned skeleton application
you need to install the platform:
npm install @rxstack/platform --save
Now register the module in the APP_OPTIONS
import {ApplicationOptions} from '@rxstack/core';
import {PlatformModule} from '@rxstack/platform';
export const APP_OPTIONS: ApplicationOptions = {
imports: [
// ...
PlatformModule,
]
};
Documentation
These steps will guide you through creating your first appication.
Before you start you need to have basic understanding of RxStack framework
Models
Model
is a simple typescript interface
. Let's create our first model:
Models are not required but it is a good practice to define them
// src/app/resources/task/task.model.ts
export interface TaskModel {
id: string;
name: string;
completed: boolean;
}
Services
Services provide a uniform, protocol independent interface for reading or writing data.
Every service implements ServiceInterface
.
Options:
The base options are:
idField
: unique entity identifierdefaultLimit
: limit of the fetched entities
There'll be additional options depending on adapter
Methods
Service methods are pre-defined CRUD operations.
Each service method has optional parameter
options
which might be passed to the native driver.
insertOne
Creates a new entry. The method should return a Promise with the newly created entry.
const data: Object; // entry data
const newEntry = await service.insertOne(data);
insertMany
Creates entries. The method should return a Promise with the newly created entries.
const data: Object[]; // entries data
const newEntries = await service.insertMany(data);
updateOne
Replaces the entry by id. The method should return a Promise.
const id: any; // entry id
const data: Object; // entry data
const entry = await service.updateOne(id, data);
updateMany
Patches entries by criteria. The method should return a Promise with number of updated entries.
const criteria = {
id: { '$in': ['id-1', 'id-2'] }
};
const data: Object = { completed: true };
const affectedEntries = await service.updateMany(criteria, data);
removeOne
Deletes the entry by id. The method should return a Promise.
const id: any; // entry id
await service.removeOne(id);
removeMany
Deletes entries by criteria. The method should return a Promise with number of deleted entries.
const criteria = {
id: { '$in': ['id-1', 'id-2'] }
};
const affectedEntries = await service.removeMany(criteria);
count
Counts entries by optional parameter criteria. The method should return a Promise with number of entries.
const criteria = {
id: { '$in': ['id-1', 'id-2'] }
};
const cnt = await service.count(criteria);
find
Find entry by id. The method should return a Promise with the found entry or null.
const entry = await service.find(id);
findOne
Finds a single entry by criteria. The method should return a Promise with the found entry or null.
Always returns the first found entry
const criteria = {
id: { '$in': ['id-1'] }
};
const entry = await service.findOne(criteria);
findMany
Finds, limits, slices and sorts entries by query. The method should return a Promise with found entries.
const query = {
where: {
id: { '$in': ['id-1', 'id-2'] },
limit: 10,
skip: 0,
sort: {id: -1}
},
};
const entries = await service.findMany(criteria);
Querying
Querying is done via @rxstack/query-filter
component.
findMany
accepts QueryInterface.findOne
,updateMany
,removeMany
andcount
accept criteria object with@rxstack/query-filter
operators.find
query by entity identifier
Adapters
Official adapters:
For the sake of simplicity in the example below we're going to use @rxstack/memory-service
.
You need to install and configure the module.
Let's create TaskService
:
// src/app/resources/task/task.service.ts
import {TaskModel} from './task.model';
import {Injectable} from 'injection-js';
import {MemoryService} from '@rxstack/memory-service';
@Injectable()
export class TaskService extends MemoryService<TaskModel> {
// you can add more methods or overwrite the existing ones
}
we need to register the service:
// src/app/resources/task/APP_TASK_PROVIDERS.ts
import {ProviderDefinition} from '@rxstack/core';
import {TaskService} from './task.service';
export const APP_TASK_PROVIDERS: ProviderDefinition[] = [
// ...
{
provide: TaskService,
useFactory: () => new TaskService({
idField: 'id', defaultLimit: 25, collection: 'tasks'
}),
deps: [],
},
];
Make sure APP_TASK_PROVIDERS are registered in the APP_OPTIONS
Operations
An operation is a link between a Resource
, Service
and Action Controller
.
Each operation supports preExecute
and postExecute
hooks.
You can use these middleware to modify query, validate, authorize etc...
The platform is shipped with a set of helpers you can use in your daily work.
The following operations are supported by the platform:
All operations need to be registered in the application providers!
List
Fetches entries from the data storage
Operation uses service method
findMany
.
// src/app/resources/task/task-list.operation.ts
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskModel} from './task.model';
import {TaskService} from './task.service';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.LIST,
name: 'app_task_list',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks', // required only if using HTTP transport
service: TaskService,
})
export class TaskListOperation extends AbstractResourceOperation<TaskModel> { }
and register it in the task providers:
import {ProviderDefinition} from '@rxstack/core';
import {TaskListOperation} from './task-list.operation';
export const APP_TASK_PROVIDERS: ProviderDefinition[] = [
// ...
{ provide: TaskListOperation, useClass: TaskListOperation }
];
cURL:
curl -X GET \
http://0.0.0.0:3000/tasks \
-H 'Accept: application/json'
Pagination
To enable pagination:
@Operation<ResourceOperationMetadata<Task>>({
// ...
pagination: {
enabled: true,
limit: 10 //optional, default to limit defined in the service
}
})
Pagination data is located in request.attributes.get('pagination')
. To overwrite it use postExecute
hook.
@Operation<ResourceOperationMetadata<Task>>({
// ...
onPostExecute: [
async (event: OperationEvent): Promise<void> => {
const pagination: Pagination = event.request.attributes.get('pagination');
// do something with pagination
event.request.attributes.set('pagination', pagination);
}
]
})
On kernel.response
event headers are set in the Response
:
x-total
x-limit
x-skip
cURL:
curl -X GET \
http://0.0.0.0:3000/tasks?$limit=10&$skip=0 \
-H 'Accept: application/json'
Applying query filters
Query object is stored in request.attributes.get('query')
. To overwrite it use preExecute
hook.
@Operation<ResourceOperationMetadata<Task>>({
// ...
preExecute: [
// ...
async (event: OperationEvent): Promise<void> => {
const query: QueryInterface = event.request.attributes.get('query');
event.request.attributes.set('query', {...query, ...{'where': {'completed': {'$eq': true}}}});
}
]
})
For advanced querying use @rxstack/platform-callbacks In that case you'll be able to apply query filters from request parameters, security token ...
cURL:
curl -X GET \
'http://0.0.0.0:3000/tasks?id[$in]=1,2'
Create
Create a new data entry.
Operation uses service method
insertOne
.
// src/app/resources/task/create-task.operation.ts
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskModel} from './task.model';
import {TaskService} from './task.service';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.CREATE,
name: 'app_task_create',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks', // required only if using HTTP transport
service: TaskService
})
export class TaskCreateOperation extends AbstractResourceOperation<TaskModel> { }
You need to register in the providers.
For validation and etc. use @rxstack/platform-callbacks
cURL:
curl -X POST \
http://0.0.0.0:3000/tasks \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"name": "task-1",
"completed": false
}'
Get
Get a single data entry by its unique identifier.
Operation uses service method
find
.
// src/app/resources/task/get-task.operation.ts
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.GET,
name: 'app_task_get',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks/:id', // required only if using HTTP transport
service: TaskService,
})
export class TaskGetOperation extends AbstractResourceOperation<TaskModel> { }
For transformation and etc. use @rxstack/platform-callbacks
cURL:
curl -X GET \
http://localhost:3000/tasks/task-1 \
-H 'Content-Type: application/json'
By default it looks for request.param.get('id')
. To replace the query just overwritefindOneOr404
.
import {Request} from '@rxstack/core';
import {NotFoundException} from '@rxstack/exceptions';
// ...
export class TaskGetOperation extends AbstractResourceOperation<Task> {
protected async findOneOr404(request: Request): Promise<TaskModel> {
// replacing `find` with `findOne`
const resource = await this.getService()
.findOne({'id': {'$eq': request.params.get('id')}, 'completed': {'$eq': true}});
if (!resource) {
throw new NotFoundException();
}
return resource;
}
}
Update
Update an existing data entry by completely replacing it.
Entry needs to be fetched from data storage before updated. To replace the query just overwritefindOneOr404
.
Operation uses service method
updateOne
.
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.UPDATE,
name: 'app_task_update',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks/:id',
service: TaskService
})
export class TaskUpdateOperation extends AbstractResourceOperation<TaskModel> { }
cURL:
curl -X PUT \
http://localhost:3000/tasks/task-1 \
-H 'Content-Type: application/json' \
-d '{
"name": "task 1.1",
"completed": "true"
}'
Patch
Update one or more data entries by merging with the new data. By default it looks for request.params.get('ids')
but
you can replace criteria using preExecute
hook.
Operation uses service method
updateMany
.
request.params.get('ids')
should be converted to array, ex:request.params.get('ids').split(',')
import {
AbstractResourceOperation,
Operation, OperationEvent,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.PATCH,
name: 'app_task_patch',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks/:custom_param',
service: TaskService,
onPreExecute: [
async (event: OperationEvent): Promise<void> => {
event.request.attributes.set('criteria', {
'id': { '$eq': event.request.params.get('custom_param') }
});
}
]
})
export class TaskPatchOperation extends AbstractResourceOperation<TaskModel> { }
cURL:
curl -X PATCH \
http://localhost:3000/tasks/1 \
-H 'Content-Type: application/json' \
-d '{
"completed": true
}'
Remove
Remove single entry. By default it looks for request.param.get('id')
. You can use findOneOr404
to overwrite it.
Operation uses service method
removeOne
.
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.REMOVE,
name: 'app_task_remove',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks/:id',
service: TaskService
})
export class TaskRemoveOperation extends AbstractResourceOperation<TaskModel> { }
cURL:
curl -X DELETE \
http://localhost:3000/tasks/id-1
Bulk Create
Create many entries at once.
Operation uses service method
insertMany
.
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.BULK_CREATE,
name: 'app_task_bulk_create',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks-bulk-create',
service: TaskService
})
export class TaskBulkCreateOperation extends AbstractResourceOperation<TaskModel> { }
cURL:
curl -X POST \
http://localhost:3000/tasks-bulk-create \
-H 'Content-Type: application/json' \
-d '[
{
"name": "task-1",
"completed": true
},
{
"name": "task-2",
"completed": false
}
]'
Bulk Remove
Remove many entries at once. By default it looks for request.params.get('ids')
but
you can replace criteria using preExecute
hook.
Operation uses service method
removeMany
.
import {
AbstractResourceOperation,
Operation,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.BULK_REMOVE,
name: 'app_task_bulk_remove',
transports: ['HTTP', 'SOCKET'],
httpPath: '/tasks-bulk-delete',
service: TaskService
})
export class TaskBulkRemoveOperation extends AbstractResourceOperation<TaskModel> { }
cURL:
curl -X DELETE \
http://localhost:3000/tasks-bulk-delete?ids=1,2
request.params.get('ids')
should be converted to array, ex:request.params.get('ids').split(',')
Hooks
Hooks are async middleware functions that can be registered in operations. You can use them to validate, authorize and etc...
At the end they are converted to observers
.
Function accepts a single parameter event
which is instance of OperationEvent
and returns Promise<void>
.
OperationEvent
extends GenericEvent
and contains the following properties and methods.
request
: current requestinjector
: dependency injectormetadata
: operation readonly metadatastatusCode
: sets status code of theResponse
objecteventType
:preExecute
,postExecute
or user-defined type.setData
: with this method you can set data before passed toResponse
object.getData
: retrieve the data set by the operation methodsresponse
: if you set the response then it will immediately stop propagation and will send the response to the client.
// ...
onPreExecute: [
async (event: OperationEvent): Promise<void> => {
// do something on preExecute
},
],
onPostExecute: [
async (event: OperationEvent): Promise<void> => {
// do something on postExecute
},
]
You can also register observers instead of hooks. The event name consists operation.name
and eventType
import {Injectable} from 'injection-js';
import {Observe} from '@rxstack/async-event-dispatcher';
import {OperationEvent} from '@rxstack/platform';
@Injectable()
export class TaskObserver {
@Observe('app_task_create.pre_execute', 100)
async onPreExecute(event: OperationEvent): Promise<void> {
// do something
}
}
Do not forget to register it in the application providers.
You can execute a collection of hooks as a single hook, and then you can use it in multiple operations:
import {
associateWithCurrentUser,
restrictToRole, setNow,
} from '@rxstack/platform-callbacks';
export const taskPreExecuteCallback = (): OperationCallback => {
return async (event: OperationEvent): Promise<void> => {
// first
await restrictToRole('ROLE_ADMIN')(event);
// second
await associateWithCurrentUser({
idField: 'username',
targetField: 'createdBy'
})(event);
// third
await setNow('createdAt')(event);
};
// in operation metadata
onPreExecute: [
taskPreExecuteCallback()
]
For more information how to create configurable hooks check these example
Overwrite operations
The easies way to overwrite default behavior of operation is to use preExecute
and postExecute
hooks. You can also overwrite
doExecute
method.
doExecute
is called by all operations including the custom ones.
import {
AbstractResourceOperation,
Operation, OperationEvent,
ResourceOperationMetadata,
ResourceOperationTypesEnum
} from '@rxstack/platform';
import {TaskService} from './task.service';
import {TaskModel} from './task.model';
@Operation<ResourceOperationMetadata<TaskModel>>({
type: ResourceOperationTypesEnum.CREATE,
name: 'app_task_create',
transports: ['SOCKET'],
service: TaskService,
})
export class CreateTaskOperation extends AbstractResourceOperation<TaskModel> {
protected async doExecute(event: OperationEvent): Promise<void> {
// apply you logic here
// event.setData(your data) - to set the result
}
}
Custom Operations
In some cases specific operations are needed for example to send an email, then custom operation comes in place.
Metadata
Each operation needs a metadata.OperationMetadata can be used or if additional configurations are needed then it can be extended.
import {OperationCallback, OperationMetadata} from '@rxstack/platform';
import {Type} from 'injection-js/facade/type';
import {InjectionToken} from 'injection-js';
export interface MailerServiceInterface {
send(recipient: string, template: string, data: Object): Promise<void>;
}
export interface SendMailOperationMetadata extends OperationMetadata {
onPreSend?: OperationCallback[]; // custom hook
onPostSend?: OperationCallback[]; // another custom hook
recipient: string;
mailerService: Type<MailerServiceInterface> | InjectionToken<MailerServiceInterface>;
template: string;
}
If you want to register other observers then you need to add a property in metadata starting with
^on
. For example:onPreSend
oronPostSend
. They will be automatically registered with the event dispatcher.
Implementation
Each operation must extend AbstractOperation
. Async method doExecute
is called every time an operation is executed.
Workflow: preExecute
-> doExecute
-> postExecute
Let's create our abstract custom operation SendMailOperation
:
import {AbstractOperation, OperationEvent} from '@rxstack/platform';
import {AsyncEventDispatcher} from '@rxstack/async-event-dispatcher';
export abstract class SendMailOperation extends AbstractOperation {
metadata: SendMailOperationMetadata;
protected async doExecute(event: OperationEvent): Promise<void> {
const mailService = this.injector.get(this.metadata.mailerService);
const dispatcher = event.injector.get(AsyncEventDispatcher);
await dispatcher.dispatch(this.metadata.name + '.' + 'preSend');
await mailService.send(this.metadata.recipient, this.metadata.template, event.request.params.toObject());
await dispatcher.dispatch(this.metadata.name + '.' + 'postSend');
}
}
and now the implementation:
@Operation<SendMailOperationMetadata>({
name: 'send_email',
transports: ['SOCKET'],
recipient: '[email protected]',
mailerService: MailerService, // service that implement MailerServiceInterface
template: `
Hello world
`,
onPreSend: [
async (event: OperationEvent): Promise<void> => {
// do something
}
],
onPostSend: [
async (event: OperationEvent): Promise<void> => {
// do something
}
]
})
@Injectable()
export class SendMailOperationImpl extends SendMailOperation { }
Do not forget to register it in the application providers
As you see you can create any type of configurable operations with ease.
Testing
Automated tests are very important part of application development.
Operation Testing
We are going to use integration tests. You just need to get the definition
from the kernel
.
There is no need of running server.
import 'reflect-metadata';
import {Injector} from 'injection-js';
import {Application, Kernel, Request, Response} from '@rxstack/core';
import {APP_OPTIONS} from '../src/app/APP_OPTIONS';
describe('Platform:Operation:Create', () => {
// Setup application
const app = new Application(APP_OPTIONS);
let injector: Injector;
let kernel: Kernel;
before(async() => {
injector = await app.run();
kernel = injector.get(Kernel);
});
it('@app_task_create', async () => {
// getting the http definition
const def = kernel.httpDefinitions.find((def) => def.name === 'app_task_create');
// building the request
const request = new Request('HTTP');
request.body = { 'name': 'my task', 'completed': false };
// getting the response
const response: Response = await def.handler(request);
// testing against the response data
response.statusCode.should.equal(201);
response.content['name'].should.equal('my task');
});
});
Hook Testing
We are going to use unit testing with sinon
.
import 'reflect-metadata';
import {Injector} from 'injection-js';
import {Request} from '@rxstack/core';
import {OperationEvent, OperationEventsEnum, ResourceOperationMetadata} from '@rxstack/platform';
import {TaskModel} from '../src/app/resources/task/task.model';
const sinon = require('sinon');
// create injector
const injector = sinon.createStubInstance(Injector);
// create metadata
const app_create_metadata = { } as ResourceOperationMetadata<TaskModel>;
const customHook = () => {
return async (event: OperationEvent) => {
event.setData('something');
};
};
describe('My Hook', () => {
it('should output something', async () => {
// build the request
const request = new Request('HTTP');
// build the event
const apiEvent = new OperationEvent(request, injector, app_create_metadata);
apiEvent.eventType = OperationEventsEnum.POST_EXECUTE;
// pass event to the hook and execute it.
await customHook()(apiEvent);
// test againt event data
apiEvent.getData().should.equal('something');
});
});
Add-ons
Add-ons are the bridge between platform services and third-party modules.
Security add-ons
Security services help you build security layer of your application.
You need to install
SecurityModule
in order to use these add-ons
User Provider
UserProvider
is implementation of @rxstack/security
UserProviderInterface
At first place you need to create UserService
and UserModel
:
import {User} from '@rxstack/security';
export class UserModel extends User {
id: string;
}
and UserService
import {Injectable} from 'injection-js';
import {MemoryService} from '@rxstack/memory-service';
import {UserModel} from './user.model';
@Injectable()
export class UserService extends MemoryService<UserModel> {
// you can add more methods or overwrite the existing ones
}
In the APP_OPTIONS
you need to register UserProvider
and UserService
:
export const APP_OPTIONS: ApplicationOptions = {
imports: [
// ...
],
providers: [
{
provide: UserService,
useFactory: () => new UserService({
idField: 'id', defaultLimit: 25, collection: 'users'
}),
deps: [],
},
{
provide: USER_PROVIDER_REGISTRY,
useFactory: (userService: UserService) => {
return new UserProvider<UserModel>(userService, 'username');
},
deps: [UserService],
multi: true
},
};
That's all.
Refresh Token Manager
RefreshTokenManager
is implementation of @rxstack/security
AbstractRefreshTokenManager
At first place you need to create RefreshTokenService
and RefreshTokenModel
, learn more about services
In the APP_OPTIONS
you need to register RefreshTokenManager
:
export const REFRESH_TOKEN_SERVICE = new InjectionToken<ServiceInterface>('REFRESH_TOKEN_SERVICE');
export const APP_OPTIONS: ApplicationOptions = {
imports: [
// ...
],
providers: [
{
provide: REFRESH_TOKEN_SERVICE,
useFactory: () => new MemoryService({
idField: '_id', defaultLimit: 25, collection: 'refreshTokens'
}),
deps: [],
},
{
provide: REFRESH_TOKEN_MANAGER,
useFactory: (refreshTokenService: ServiceInterface<RefreshTokenInterface>, tokenEncoder: TokenEncoderInterface) => {
return new RefreshTokenManager<RefreshTokenInterface>(refreshTokenService, tokenEncoder, 100);
},
deps: [REFRESH_TOKEN_SERVICE, TOKEN_ENCODER]
}
]
};
From now on refresh token will be handled by REFRESH_TOKEN_SERVICE
.
License
Licensed under the MIT license.