0.7.1 • Published 2 years ago

@rxstack/platform v0.7.1

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

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

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

you need also to install peer dependencies (of not installed already):

npm install --no-save @rxstack/async-event-dispatcher@^0.6 @rxstack/core@^0.7 @rxstack/exceptions@^0.6 @rxstack/query-filter@^0.6 @rxstack/security@^0.7 winston@^3.3.3

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 identifier
  • defaultLimit: 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 and count 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 request
  • injector: dependency injector
  • metadata: operation readonly metadata
  • statusCode: sets status code of the Response object
  • eventType: preExecute, postExecute or user-defined type.
  • setData: with this method you can set data before passed to Response object.
  • getData: retrieve the data set by the operation methods
  • response: 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 or onPostSend. 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: 'someone@example.com',
  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');
  });
});

[read more about testing](https://github.com/rxstack/rxstack#testing)

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](https://github.com/rxstack/rxstack/tree/master/packages/security#setup) in order to use these add-ons

User Provider

UserProvider is implementation of [@rxstack/security](https://github.com/rxstack/rxstack/tree/master/packages/security#user-providers) 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](https://github.com/rxstack/rxstack/tree/master/packages/security#refresh-token-manager) AbstractRefreshTokenManager

At first place you need to create RefreshTokenService and RefreshTokenModel, [learn more about services](#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](LICENSE).

0.7.1

2 years ago

0.7.0

3 years ago

0.6.1

4 years ago

0.6.0

4 years ago

0.5.0

4 years ago

0.4.0

5 years ago

0.3.0

5 years ago

0.2.0

5 years ago

0.1.10

5 years ago

0.1.9

5 years ago

0.1.8

5 years ago

0.1.7

5 years ago

0.1.6

5 years ago

0.1.5

5 years ago

0.1.4

5 years ago

0.1.2

5 years ago

0.1.0

6 years ago