1.30.0 • Published 11 days ago

@greeneyesai/api-utils v1.30.0

Weekly downloads
-
License
LGPL-3.0-only
Repository
gitlab
Last release
11 days ago

API Utils, by GreenEyes.AI

By using this code, you can quickly setup a NodeJS REST API. Classes and utilities cover the most common patterns and practices of enterprise-grade, secure and scaling, MVC-like application architectures, suitable for production usage right out of the box.

Usage, application bootstrap

import * as path from "path";
import {
  Application,
  ApplicationEvents,
  ExecutionContext,
  IRoute,
  IRouteFactory,
  createRouteBuilder,
  CorsMiddleware,
  HeartBeatController,
  HttpMethod,
  S3Provider,
  DatabaseProvider,
  ILoggerInstance,
  IS3Config,
  ProviderDefinitionType,
  IDatabaseConfig,
  LoggerAccessor,
  NativeMiddleware,
} from "@greeneyesai/api-utils";
import BodyParserMiddleware from "body-parser"; // included in the package

const port: number = !!process.env.PORT ? Number(process.env.PORT) : 1337;

const logger: ILoggerInstance = LoggerAccessor.logger;

const routeFactory: IRouteFactory = {
  create(): IRoute[] {
    const commonApiRouteBuilder = createRouteBuilder("/common", [
      new CorsMiddleware({
        wildcard: "greeneyesai.com",
      }),
    ]);

    const commonRoutes = [
      commonApiRouteBuilder<HeartBeatController>(
        "/status",
        HttpMethod.GET,
        HeartBeatController,
        "heartBeat",
        [], // Middleware[]
      ),
    ];

    return [...commonRoutes];
  }
};

const bodyParserMiddleware: NativeMiddleware
  = BodyParserMiddleware.json({ limit: "6mb" });

const s3ProviderDefinition: ProviderDefinitionType<S3Provider, IS3Config> = {
  class: S3Provider,
  config: {
    objectTypes: ['temp', 'thumbnails'],
    baseUrl: ...,
    key: ...,
    endpoint: ...,
    accessKeyId: ...,
    secretAccessKey: ...,
    region: ...,
  }
};

const databaseProviderDefinition: ProviderDefinitionType<DatabaseProvider, IDatabaseConfig> = {
  class: DatabaseProvider,
  config: {
    debug: process.env.NODE_ENV !== "production",
    host: ...,
    port: ...,
    username: ...,
    password: ...,
    database: ...,
    ssl: true,
    modelsPath: path.resolve(__dirname, "./models"),
  }
};

const providers: ProviderDefinitionType<any, any>[] = [
  s3ProviderDefinition,
  databaseProviderDefinition,
];

(async function() {
  try {
    await new Application(port)
      .attachToContext(process)
      .setLoggerInterface(logger)
      .bindHandlerToContextEvents(
        ["uncaughtException", "unhandledRejection"],
        (_app: Application, _context: ExecutionContext, err: Error) => {
          _app.logger?.error(err);
        }
      )
      .once(
        ApplicationEvents.Closed,
        (_app: Application) => {
          _app.logger?.info(`Application closed`);
          !!_app.getContext() &&
            _app.getContext()!.exit &&
            _app.getContext()!.exit!();
        }
      )
      .bindHandlerToContextEvents(
        ["SIGTERM", "SIGUSR2"],
        (_app: Application) => {
          _app.logger?.info(`Received SIGTERM`);
          _app.logger?.onExit && _app.logger?.onExit();
          _app.notify("sigtermFromOS").close();
        }
      )
      .disableApplicationSignature()
      .allowCors()
      .addNativeMiddleware(bodyParserMiddleware) // body parser mw from above
      ...
      .configureProviders(providers)
      .mountRoutes(routeFactory)
      .addRouteNotFoundHandler()
      .addDefaultErrorHandler([
        "SequelizeDatabaseError",
        "DataCloneError",
        "connect ECONNREFUSED" /* Axios */
      ])
      .once(
        ApplicationEvents.Listening,
        (_app: Application) => {
          _app.logger?.info(
            `Application launched on port ${_app.getPort()}`
          );
        }
      )
      .listen();
  } catch (e) {
    console.error(e);
    process.exit(1);
  }
})();

Using the DatabaseProvider, with examples

  1. Create a model file like: ./models/user.ts
  2. Repurpose this example code and place it into into the file:
import { BIGINT, BOOLEAN, STRING } from "sequelize"; // included in the package
import {
  DatabaseProvider,
  DatabaseModelHelper,
  Model,
  ModelTrait,
  ModelTraitStatic,
  ModelDefinition,
  ModelStatic,
  ISchema,
  IParanoidAttributes,
  ITimestampsAttributes,
} from "@greeneyesai/api-utils";
import { v4 } from "uuid"; // included in the package

export interface IUser
  extends ISchema,
    IParanoidAttributes,
    ITimestampsAttributes {
  id: number;
  email: string;
  password: string;
  fullName: string;
  enabled: boolean;
}

export interface IUserPreview extends Omit<IUser, "password" | "deletedAt"> {}

export type ViewsTraitType = ModelTrait<
  IUser,
  {
    getPublicView(): IUserPreview;
  }
>;

export type StaticHelpersTraitType = ModelTraitStatic<
  IUser,
  {
    createWithUUID(
      this: UserModelTypeStatic,
      objectIdSelector: keyof IUser,
      seed: Partial<IUser> & Pick<IUser, "email" | "password">
    ): Promise<UserModelType>;
  }
>;

export type UserModelType = Model<IUser> & ViewsTraitType;

export type UserModelTypeStatic = ModelStatic<UserModelType> &
  StaticHelpersTraitType;

/* This is the entry function for the DatabaseProvider#getModelByName
 * to work (dynamic linking to database network connection) */
export function factory(
  databaseProvider: DatabaseProvider
): UserModelTypeStatic {
  const ViewsTrait: ViewsTraitType = {
    getPublicView: function (): IUserPreview {
      const json = DatabaseModelHelper.PATCHED_GETTER(this);
      delete json.password;
      return json;
    },
  };

  const StaticHelpersTrait: StaticHelpersTraitType = {
    createWithUUID: function (
      objectIdSelector: keyof IUser,
      seed: Partial<IUser> & Pick<IUser, "email" | "password">
    ): Promise<UserModelType> {
      return this.build({
        email: seed.email,
        password: seed.password,
        fullName: seed.fullName || "",
        enabled: seed.enabled || false,
      })
        .set(objectIdSelector, v4())
        .save();
    },
  };

  const model: ModelDefinition = DatabaseModelHelper.buildModel(
    // Table name, export this value if you use the Sequelize CLI
    "user",
    // Schema, export this value if you use the Sequelize CLI
    {
      id: {
        field: "id",
        type: BIGINT,
        primaryKey: true,
        autoIncrement: true,
      },
      email: {
        type: STRING(100),
        allowNull: false,
        unique: true,
      },
      password: {
        type: STRING(64),
        allowNull: false,
      },
      fullName: {
        type: STRING(100),
        field: "full_name",
        allowNull: false,
      },
      enabled: {
        type: BOOLEAN,
        defaultValue: false,
      },
    },
    // Traits
    [
      DatabaseModelHelper.PARANOID_MODEL_SETTINGS, // deletedAt
      DatabaseModelHelper.TIMESTAMPS_SETTINGS, // createdAt / updatedAt
    ]
  );

  const UserModel: ModelStatic<Model<IUser>> =
    databaseProvider.connection.define("User", model.schema, model.settings);

  DatabaseModelHelper.attachTraitToModel(UserModel, ViewsTrait);
  DatabaseModelHelper.attachTraitToModelStatic(UserModel, StaticHelpersTrait);

  return UserModel as UserModelTypeStatic;
}
  1. Create a controller file like: ./controllers/example-users.ts
  2. Place this example code into the file:
import { NextFunction, Request, Response } from "express"; // included in the package
import {
  Controller,
  ControllerError,
  DatabaseProvider,
  SingletonFactory,
  ModelStatic,
  ResponseFormatter,
  SingletonClassType,
} from "@greeneyesai/api-utils";
import {
  IUserPreview,
  UserModelType,
  UserModelTypeStatic,
} from "../models/user";

export class ExampleUsersController extends Controller {
  static get Dependencies(): [SingletonClassType<DatabaseProvider>] {
    return [DatabaseProvider];
  }

  constructor(protected _databaseProvider: DatabaseProvider) {
    super();
  }

  async getUserById(
    req: Request<{ userId: string }>,
    res: Response,
    next: NextFunction
  ) {
    try {
      const userId: number = parseInt(req.params.userId, 10);

      if (Number.isNaN(userId)) {
        throw new ControllerError(`User id ${userId} invalid`);
      }

      this.logger?.info(`Getting user by id ${userId}`);

      const UserModel: UserModelTypeStatic =
        this._databaseProvider.getModelByName<UserModelTypeStatic>("user")!;

      const user: UserModelType | null = await UserModel.findOne({
        where: {
          id: userId,
        },
      });

      if (!user) {
        const notFoundError = new ControllerError("User not found");
        res
          .status(404)
          .json(new ResponseFormatter(notFoundError).toErrorResponse());
        throw notFoundError;
      }

      const view: IUserPreview = user.getPublicView();
      res.status(200).json(new ResponseFormatter(view).toResponse());
    } catch (e) {
      const err = (
        e instanceof ErrorLike ? e : ErrorLike.createFromError(e as Error)
      )
        .clone()
        .setContext(this.context)
        .setOriginator(this)
        .setMetadata({
          path: req.path,
        });
      return next(err);
    }
  }
}
  1. Wire in the route similarly to how it was described above:
...
    ...
        publicApiRouteBuilder<ExampleUsersController>(
          "/users/:userId",
          HttpMethod.GET,
          ExampleUsersController,
          "getUserById",
          [],
        ),
    ...
...

Migrations should be prepared separatelly. You can use the sequelize-cli to run them, or alternativelly you can use the Model.sync() method like this:

1.) Create DatabaseSynchronizer provider class and place it in the ./providers/database-synchronizer.ts file:

import {
  SingletonClassType,
  DatabaseProvider,
  StoreProviderEvents,
  StoreProviderError,
  Provider,
  ModelStatic,
} from "@greeneyesai/api-utils";

export interface IDatabaseSynchronizerConfig extends Array<string> {}

export class DatabaseSynchronizer extends Provider<IDatabaseSynchronizerConfig> {
  static get Dependencies(): [SingletonClassType<DatabaseProvider>] {
    return [DatabaseProvider];
  }

  constructor(protected _databaseProvider: DatabaseProvider) {
    super();
  }

  configure(config: IDatabaseSynchronizerConfig) {
    super.configure(config);
    this._databaseProvider.once(
      StoreProviderEvents.Connected,
      this.createModelsSyncHandler()
    );
  }

  protected createModelsSyncHandler(): (
    _databaseProvider: DatabaseProvider
  ) => Promise<void> {
    return async (_databaseProvider: DatabaseProvider): Promise<void> => {
      try {
        for (let modelPath of this._config!) {
          const CurrentModel: ModelStatic<any> =
            _databaseProvider.getModelByName<ModelStatic<any>>(modelPath)!;
          await CurrentModel.sync();
          _databaseProvider.logger?.info(
            `${this.className}->createModelsSyncHandler()[nested] Model for table "${CurrentModel.tableName}" synced.`
          );
        }
      } catch (e) {
        const err = (e as StoreProviderError).clone();
        err.stack = `${this.className}->createModelsSyncHandler()[nested] Error: ${err.stack}`;
        _databaseProvider.logger?.error(err);
      }
    };
  }
}

2.) Add the provider to the providers array in the application boostrap file:

...

const databaseSynchronizerDefinition: ProviderDefinitionType<
  DatabaseSynchronizer,
  IDatabaseSynchronizerConfig
> = {
  class: DatabaseSynchronizer,
  config: ["user"],
};

...

providers.push(databaseSynchronizerDefinition);

...

(async function() {
  try {
    await new Application(port)
      ...
      .configureProviders(providers)
      ...
      listen();
  } catch (e) {
    console.error(e);
    process.exit(1);
  }
})();

...

Thats it.


What about middlewares?

A recommended pattern:

import {
  CacheProvider,
  CacheProviderWithProxiedClientType,
  Middleware,
} from "@greeneyesai/api-utils";
import { NextFunction, Request, Response } from "express";

export interface IXYZMiddlewareConfig {
  prefix: string;
}

export class XYZMiddleware extends Middleware {
  protected get _defaultOpts(): IXYZMiddlewareConfig {
    return {
      prefix: "XYZPrefix",
    };
  }

  ...
  static create(opts: Partial<IXYZMiddlewareConfig> = {}): XYZMiddleware {
    const cacheProvider = CacheProvider.instance;
    return new this(cacheProvider, opts);
  }

  constructor(
    protected _cacheProvider: CacheProviderWithProxiedClientType,
    protected _opts: Partial<IXYZMiddlewareConfig> = {},
  ) {
    super();
    this._opts = Object.assign({}, this._defaultOpts, this._opts);
  }

  async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
    try {
      const yourKey = `${this._opts.prefix}:${req.params.userId}`;
      ...
      const whateverValue = await this._cacheProvider.get(yourKey);
      ...
      await this._cacheProvider.set(yourKey, yourValue);
      ...
    } catch (e) {
      return next(e);
    }
    return next();
  }
}

Usage:

...
    ...
        publicApiRouteBuilder<ExampleUsersController>(
          "/users/:userId",
          HttpMethod.GET,
          ExampleUsersController,
          "getUserById",
          [
            ...
            XYZMiddleware.create({
              prefix: "XYZPrefixAltered",
            }),
            ...
          ],
        ),
    ...
...

Cron jobs?

See the example:

import { CronJob } from "@greeneyesai/api-utils";
...
export class XYZCronJob extends CronJob {
  public static get ScheduledFor(): string {
    return "0 0 * * *"; // every midnight
  }

  public static create(token: string) {
    return new this(token);
  }

  async run(): Promise<void> {
    this.logger?.info(
      `${this.className}->run Hello from cronjob, token: ${this.token}`
    );
  }
}
...

Configure provider:

import {
  ...,
  ProviderDefinitionType,
  CacheProvider, // CronProvider depends on this
  CronProvider,
  ICronProviderConfig,
  ...
} from "@greeneyesai/api-utils";
import { XYZCronJob } from "./jobs/xzy"
...
const cronProviderDefinition: ProviderDefinitionType<CronProvider, ICronProviderConfig> = {
  class: CronProvider,
  config: [XYZCronJob]
};
...
providers.push(cronProviderDefinition);
...

Workers?

Create a TestWorker class like this:

import {
  Worker,
  IWorkerFactory,
  IWorkerConfig,
  SingletonClassType,
} from "@greeneyesai/api-utils";

export type TestWorkerResultType = boolean;

export interface ITestWorkerConfig extends IWorkerConfig {}

export class TestWorker extends Worker<
  TestWorkerResultType,
  ITestWorkerConfig
> {
  // Must implement:
  protected static getCurrentFilePath(): string {
    return super.resolve(__dirname, __filename);
  }

  public static get InjectorToken(): symbol {
    return Symbol.for(TestWorker.name);
  }

  public static get Dependencies(): SingletonClassType<any>[] {
    return [];
  }

  public configure(config: ITestWorkerConfig): void {
    super.configure(config);
  }

  public async run(): Promise<TestWorkerResultType> {
    this.logger?.info(`Worker runs: ${this.token}`);
    return true;
  }
}

export const TestWorkerFactory: IWorkerFactory<
  TestWorkerResultType,
  ITestWorkerConfig
> = TestWorker.getWorkerFactory<TestWorkerResultType, ITestWorkerConfig>();

Usage:

import { WorkerInstance, WorkerError } from "@greeneyesai/api-utils";
import {
  TestWorkerFactory,
  TestWorkerResultType,
  ITestWorkerConfig,
} from "./workers/test";
...
  ...
    try {
      const config: ITestWorkerConfig = {};
      const worker: WorkerInstance<TestWorkerResultType> =
        TestWorkerFactory.createAndRunWorker(config);
      this.logger?.info(`Worker created with token: ${worker.token}`);
      const result: TestWorkerResultType = await worker.getResult();
      this.logger?.info(`Result: ${result}`);
    } catch (e) {
      const err = (
        (e instanceof WorkerError)
          ?
          e
          :
          WorkerError.createFromError(e as Error)
        ).clone();
      throw err;
    }
  ...
...

Cryptography

import {
  ...,
  ProviderDefinitionType,
  Cryptography,
  ICryptographyConfig,
  ...
} from "@greeneyesai/api-utils";
...
const cryptographyDefinition: ProviderDefinitionType<Cryptography, ICryptographyConfig> = {
  class: Cryptography,
  config: {
    passwordSalt: process.env.PASSWORD_SALT!,
    passwordSaltInTransmit: process.env.PASSWORD_IN_TRANSMIT_SALT,
    ...
  }
};
...
providers.push(cryptographyDefinition);
...

To see the complete source of the package, please use the Code tab on the NPM page.


Release notes

1.29.0

  • type added for createRouteBuilder: RouteBuilder

1.27.*

  • native Error extensions via CallTracingErrorWrapper, such as Error->setToken(token: string)

1.25.*

  • error constructor extensions via CallTracingErrorWrapper
  • error format, token unwrap
  • ChainingHasher<T>, ChainingHasherType, ChainingHasherError:
...
  const hasher: Cryptography.ChainingHasherType = 
    this._cryptography.createChainingHasher("text to hash");
  const hash: string = 
    hasher.pipe("hashPassword")
      .pipe("hashPasswordForTransmission")
      .pipe("hashWithCustomSalt", someRandomSalt)
      .getHash();
...

1.22.*

  • error handling in CacheProviderClientProxy

1.20.*

  • Cryptography.JSONWebToken class and RequestIdMiddleware
  • patching logger instance commands to accept callbacks
  • stack trace filtering (based on context switches within the logger transport)
  • CallTracingErrorWrapper
  • logger.info(\\MSG\, { shouldTrace: true }); will print the stack trace under the message

1.19.*

  • RequestIdMiddleware and RequestWithId type

1.18.*

  • Cryptography
  • logger debug mode: LoggerAccessor.setLogLevel(LogLevel.DEBUG)
  • StoreProviderAbstract renamed to StoreProvider
  • IProvider renamed to ProviderDefinitionType

1.17.*

  • Worker abstraction refactoring, added WorkerInstance class (see example code) and IWorkerFactory

1.16.*

  • introducing SecretResolver provider for AWS Secrets Manager

1.15.*

  • introducing Worker class

1.14.*

  • introducing CronProvider, CacheTokenizer, CacheTokenizerError implementations
  • introducing CronTokenizer and CronJob abstract classes, CronJobError and bunch of new helper interfaces

1.13.*

  • Circular dependency detection in SingletonFactory.
  • Added SingletonFactoryError type.
  • Type tweaks around Singleton abstract class.

1.11.*

  • Timestamps and Paranoid fields now exported from DatabaseModelHelper, so you can add them to your migrations from reference
  • typings for mutating singleton getters (see CacheProvider.instance)
  • Singleton.castToSelf(...) now does run-time assertion of the class specified as target before casting the type
  • smaller improvements like StoreProviderError now a subclass of ProviderError

1.10.*

  • Fixing a bug in CacheProviderClientProxy
  • Patches around the native Object extensions

1.7.*

  • added LoggerAccessor built on top of @greeneyesai/winston-console-transport-in-worker NPM package, for performant logging

1.6.*

  • cache provider CacheProvider.instance now returns CacheProviderWithProxiedClient
  • added support for Singleton.create factory method, which will be used in the SingletonFactory on Singleton instance creation, if the child class defines it
  • ILoggerInstance#onExit introduced (optional, if you need to flush the logger sometime)
  • Object.respondsToSelector introduced globally using the reflection API, so you can assert if any Object is capable of receiving calls under a selector you pass
  • improved type safety

1.5.*

  • resolved ErrorLike message supression
  • Providers which use InjectorToken from parent class, if not modified, can extend parent Providers while Dependencies[] in all other places does not need to be changed anywhere
  • default error handler of Application only emits the error if headers are not sent prior

1.4.0

  • New types and breaking changes
  • New singleton creation pattern, introducing SingletonFactory

1.3.8

  • readme update

1.3.7

  • fixing Application->allowCors method

1.3.5

  • readme update

1.3.3

  • change in the DatabaseProvider->getModelByName type definition
  • readme update regarding to the model construction (UserModelTypeStatic)

1.3.1

  • improved ModelTrait and ModelTraitStatic types

1.3.0

  • new types: ModelTrait and ModelTraitStatic
  • new helpers: DatabaseModelHelper.attachTraitToModel and DatabaseModelHelper.attachTraitToModelStatic
  • improved error handling
  • improved base class for providers
  • improved logging in default providers

License

GNU Lesser General Public License v3.0

© GreenEyes Artificial Intelligence Services, LLC.

1.29.1

11 days ago

1.30.0

11 days ago

1.29.0

2 months ago

1.28.1

3 months ago

1.28.0

3 months ago

1.27.27

3 months ago

1.27.26

3 months ago

1.27.28

3 months ago

1.27.23

3 months ago

1.27.22

3 months ago

1.27.25

3 months ago

1.27.24

3 months ago

1.27.21

3 months ago

1.27.20

3 months ago

1.27.19

3 months ago

1.27.16

3 months ago

1.27.15

3 months ago

1.27.18

3 months ago

1.27.17

3 months ago

1.27.12

3 months ago

1.27.11

3 months ago

1.27.14

3 months ago

1.27.13

3 months ago

1.27.10

3 months ago

1.27.2

3 months ago

1.27.1

3 months ago

1.27.6

3 months ago

1.27.7

3 months ago

1.27.4

3 months ago

1.27.5

3 months ago

1.27.8

3 months ago

1.27.9

3 months ago

1.26.3

3 months ago

1.26.2

3 months ago

1.27.0

3 months ago

1.25.18

3 months ago

1.25.13

3 months ago

1.25.8

3 months ago

1.25.9

3 months ago

1.25.6

3 months ago

1.25.7

3 months ago

1.25.11

3 months ago

1.25.12

3 months ago

1.25.10

3 months ago

1.26.0

3 months ago

1.26.1

3 months ago

1.12.15

3 months ago

1.12.18

3 months ago

1.12.17

3 months ago

1.25.1

3 months ago

1.25.4

3 months ago

1.25.5

3 months ago

1.25.2

3 months ago

1.25.3

3 months ago

1.22.10

3 months ago

1.22.19

3 months ago

1.25.0

3 months ago

1.22.22

3 months ago

1.22.21

3 months ago

1.22.20

3 months ago

1.22.3

3 months ago

1.22.4

3 months ago

1.22.7

3 months ago

1.22.8

3 months ago

1.22.5

3 months ago

1.22.6

3 months ago

1.22.9

3 months ago

1.23.2

3 months ago

1.23.0

3 months ago

1.23.1

3 months ago

1.24.0

3 months ago

1.21.0

3 months ago

1.20.16

3 months ago

1.20.17

3 months ago

1.20.19

3 months ago

1.20.11

3 months ago

1.20.13

3 months ago

1.20.14

3 months ago

1.20.15

3 months ago

1.22.0

3 months ago

1.22.1

3 months ago

1.22.2

3 months ago

1.20.20

3 months ago

1.20.21

3 months ago

1.20.22

3 months ago

1.20.1

3 months ago

1.20.2

3 months ago

1.20.5

3 months ago

1.20.6

3 months ago

1.20.3

3 months ago

1.20.4

3 months ago

1.20.7

3 months ago

1.20.0

4 months ago

1.18.1

4 months ago

1.18.5

4 months ago

1.18.4

4 months ago

1.18.3

4 months ago

1.18.2

4 months ago

1.18.9

4 months ago

1.18.8

4 months ago

1.18.7

4 months ago

1.18.6

4 months ago

1.19.0

4 months ago

1.19.1

4 months ago

1.18.0

4 months ago

1.17.11

4 months ago

1.17.10

4 months ago

1.17.15

4 months ago

1.17.14

4 months ago

1.17.13

4 months ago

1.17.12

4 months ago

1.17.2

4 months ago

1.17.1

4 months ago

1.17.0

4 months ago

1.17.6

4 months ago

1.17.5

4 months ago

1.17.4

4 months ago

1.17.3

4 months ago

1.17.9

4 months ago

1.17.8

4 months ago

1.17.7

4 months ago

1.15.8

4 months ago

1.15.9

4 months ago

1.16.2

4 months ago

1.16.1

4 months ago

1.16.0

4 months ago

1.15.10

4 months ago

1.15.11

4 months ago

1.14.1

4 months ago

1.14.0

4 months ago

1.14.3

4 months ago

1.14.2

4 months ago

1.15.0

4 months ago

1.15.4

4 months ago

1.15.3

4 months ago

1.15.2

4 months ago

1.15.1

4 months ago

1.15.7

4 months ago

1.15.6

4 months ago

1.15.5

4 months ago

1.10.2

4 months ago

1.11.10

4 months ago

1.11.11

4 months ago

1.11.4

4 months ago

1.11.3

4 months ago

1.11.2

4 months ago

1.11.1

4 months ago

1.11.7

4 months ago

1.11.6

4 months ago

1.11.5

4 months ago

1.11.0

4 months ago

1.13.2

4 months ago

1.13.1

4 months ago

1.13.0

4 months ago

1.13.6

4 months ago

1.13.5

4 months ago

1.13.4

4 months ago

1.13.3

4 months ago

1.13.9

4 months ago

1.13.8

4 months ago

1.13.10

4 months ago

1.13.7

4 months ago

1.10.1

4 months ago

1.8.1

4 months ago

1.8.0

4 months ago

1.6.1

4 months ago

1.6.0

4 months ago

1.7.4

4 months ago

1.7.3

4 months ago

1.9.0

4 months ago

1.7.2

4 months ago

1.7.0

4 months ago

1.10.0

4 months ago

1.4.1

5 months ago

1.5.8

5 months ago

1.5.7

5 months ago

1.5.6

5 months ago

1.5.5

5 months ago

1.5.4

5 months ago

1.5.3

5 months ago

1.5.2

5 months ago

1.5.1

5 months ago

1.5.0

5 months ago

1.4.0

5 months ago

1.3.8

5 months ago

1.2.0

5 months ago

1.3.7

5 months ago

1.3.6

5 months ago

1.3.5

5 months ago

1.3.4

5 months ago

1.3.3

5 months ago

1.3.2

5 months ago

1.3.1

5 months ago

1.3.0

5 months ago

1.1.18

5 months ago

1.1.1

5 months ago

1.1.0

5 months ago

1.1.9

5 months ago

1.1.8

5 months ago

1.1.7

5 months ago

1.1.6

5 months ago

1.1.5

5 months ago

1.1.4

5 months ago

1.1.3

5 months ago

1.1.2

5 months ago

1.1.12

5 months ago

1.1.11

5 months ago

1.1.10

5 months ago

1.1.16

5 months ago

1.1.15

5 months ago

1.1.14

5 months ago

1.1.13

5 months ago

1.1.17

5 months ago

1.0.29

5 months ago

1.0.28

5 months ago

1.0.32

5 months ago

1.0.31

5 months ago

1.0.30

5 months ago

1.0.19

5 months ago

1.0.18

5 months ago

1.0.22

5 months ago

1.0.21

5 months ago

1.0.20

5 months ago

1.0.26

5 months ago

1.0.25

5 months ago

1.0.24

5 months ago

1.0.23

5 months ago

1.0.27

5 months ago

1.0.17

5 months ago

1.0.16

5 months ago

1.0.15

5 months ago

1.0.14

5 months ago

1.0.13

5 months ago

1.0.11

5 months ago

1.0.9

5 months ago

1.0.8

5 months ago

1.0.7

5 months ago

1.0.6

5 months ago

1.0.5

5 months ago

1.0.4

5 months ago

1.0.3

5 months ago

1.0.2

5 months ago

1.0.1

6 months ago

1.0.0

6 months ago