Skip to content

Commit

Permalink
Added s# cache implementation.
Browse files Browse the repository at this point in the history
  • Loading branch information
julienblin committed Jun 14, 2018
1 parent d661db7 commit 891533c
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 15 deletions.
146 changes: 146 additions & 0 deletions src/services/aws/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { S3 } from "aws-sdk";
import * as HttpStatusCodes from "http-status-codes";
import { randomStr } from "../../core/utils";
import { Cache } from "../cache";
import { checkHealth, CheckHealth } from "../health-check";
import { S3Client } from "./s3-client";

/** Options fro S3Cache */
export interface S3CacheOptions {
/** S3 bucket name. */
bucket: string | Promise<string>;

/** The content type for the files. */
contentType?: string;

/** Base path to use in the bucket. */
path?: string;

/** S3 client to use. */
s3?: S3Client;

/** The server side encryption to use */
serverSideEncryption?: string;

/** Custom deserializer. */
deserialize?<T>(text: string): CacheItem<T>;

/** Custom serializer. */
serialize?<T>(value: CacheItem<T>): string;
}

/**
* Cache that uses S3 as a backing store.
*/
export class S3Cache implements Cache, CheckHealth {

/** Options resolved with default values. */
private readonly options: Required<S3CacheOptions>;

public constructor(
{
bucket,
contentType = "application/json",
path = "",
s3 = new S3({ maxRetries: 3 }),
serverSideEncryption = "",
deserialize = <T>(text: string) => JSON.parse(text),
serialize = <T>(value: T) => JSON.stringify(value),
}: S3CacheOptions) {
this.options = {
bucket,
contentType,
deserialize,
path: path.endsWith("/") ? path.slice(0, -1) : path,
s3,
serialize,
serverSideEncryption,
};
}

public async checkHealth() {
return checkHealth(
"S3CacheOptions",
`${await this.options.bucket}/${this.options.path}`,
async () => {
const testKey = randomStr();
await this.set(testKey, { testKey });
await this.delete(testKey);
});
}

/** Delete the value associated with the key */
public async delete(key: string): Promise<void> {
await this.options.s3.deleteObject({
Bucket: await this.options.bucket,
Key: this.getKey(key),
}).promise();
}

/** Get the value associated with the key */
public async get<T>(key: string): Promise<T | undefined> {
try {
const response = await this.options.s3.getObject({
Bucket: await this.options.bucket,
Key: this.getKey(key),
}).promise();
if (!response.Body) {
return undefined;
}

const cacheItem = this.options.deserialize<T>(response.Body.toString());
if (cacheItem.expiresAt && cacheItem.expiresAt < (new Date().getTime() / 1000)) {
await this.delete(key);
return undefined;
}

return cacheItem.item;

} catch (error) {
if (error.statusCode === HttpStatusCodes.NOT_FOUND) {
return undefined;
}

throw error;
}
}

public async getOrFetch<T>(key: string, fetch: () => Promise<T>, useCache = true, ttl?: number): Promise<T> {
if (ttl === 0) {
return fetch();
}

if (useCache) {
const cachedValue = await this.get<T>(key);
if (cachedValue !== undefined) { return cachedValue; }
}

const value = await fetch();
await this.set(key, value, ttl);

return value;
}

/** Set the value associated with the key */
public async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const item: CacheItem<T> = {
expiresAt: ttl ? (new Date().getTime() / 1000) + ttl : undefined,
item: value,
};
await this.options.s3.putObject({
Body: this.options.serialize(item),
Bucket: await this.options.bucket,
ContentType: this.options.contentType,
Key: this.getKey(key),
ServerSideEncryption: this.options.serverSideEncryption ? this.options.serverSideEncryption : undefined,
}).promise();
}

/** Computes the path + key. */
private getKey(key: string) { return `${this.options.path}/${key}`; }
}

export interface CacheItem<T> {
expiresAt?: number;
item: T;
}
17 changes: 3 additions & 14 deletions src/services/aws/key-value-repository.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
import { AWSError, S3 } from "aws-sdk";
import { PromiseResult } from "aws-sdk/lib/request";
import { S3 } from "aws-sdk";
import * as HttpStatusCodes from "http-status-codes";
import { randomStr } from "../../core/utils";
import { checkHealth, CheckHealth } from "../health-check";
import { KeyValueRepository } from "../key-value-repository";

export interface S3Client {
deleteObject(params: S3.Types.DeleteObjectRequest)
: { promise(): Promise<PromiseResult<S3.Types.DeleteObjectOutput, AWSError>> };

getObject(params: S3.Types.GetObjectRequest)
: { promise(): Promise<PromiseResult<S3.Types.GetObjectOutput, AWSError>> };

putObject(params: S3.Types.PutObjectRequest)
: { promise(): Promise<PromiseResult<S3.Types.PutObjectOutput, AWSError>> };
}
import { S3Client } from "./s3-client";

export interface S3KeyValueRepositoryOptions {
/** S3 bucket name. */
Expand All @@ -29,7 +18,7 @@ export interface S3KeyValueRepositoryOptions {
/** S3 client to use. */
s3?: S3Client;

/** The serverside encryption to use */
/** The server side encryption to use */
serverSideEncryption?: string;

/** Custom deserializer. */
Expand Down
14 changes: 14 additions & 0 deletions src/services/aws/s3-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AWSError, S3 } from "aws-sdk";
import { PromiseResult } from "aws-sdk/lib/request";

export interface S3Client {
deleteObject(params: S3.Types.DeleteObjectRequest): {
promise(): Promise<PromiseResult<S3.Types.DeleteObjectOutput, AWSError>>;
};
getObject(params: S3.Types.GetObjectRequest): {
promise(): Promise<PromiseResult<S3.Types.GetObjectOutput, AWSError>>;
};
putObject(params: S3.Types.PutObjectRequest): {
promise(): Promise<PromiseResult<S3.Types.PutObjectOutput, AWSError>>;
};
}
1 change: 1 addition & 0 deletions src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./aws/cache";
export * from "./aws/config";
export * from "./aws/function-proxy";
export * from "./aws/key-value-repository";
Expand Down
115 changes: 115 additions & 0 deletions test/unit/services/aws/cache-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { AWSError, Response, S3 } from "aws-sdk";
import { expect } from "chai";
import * as HttpStatusCodes from "http-status-codes";
import { randomStr } from "../../../../src/core/utils";
import { S3Cache } from "../../../../src/services/aws/cache";
import { S3Client } from "../../../../src/services/aws/s3-client";
import { InMemoryCache } from "../../../../src/services/cache";

class S3ClientStub implements S3Client {

private readonly innerCache = new InMemoryCache({ defaultTtl: 3600 });

public deleteObject(params: S3.Types.DeleteObjectRequest) {
return {
promise: async () => {
await this.innerCache.delete(params.Key);

return {
$response: {} as Response<S3.Types.DeleteObjectOutput, AWSError>,
};
},
};
}

public getObject(params: S3.Types.GetObjectRequest) {
return {
promise: async () => {
const object = await this.innerCache.get(params.Key);
if (!object) {
throw {
statusCode: HttpStatusCodes.NOT_FOUND,
} as AWSError;
}
return ({
$response: {} as Response<S3.Types.GetObjectOutput, AWSError>,
Body: object,
});
},
};
}

public putObject(params: S3.Types.PutObjectRequest) {
return {
promise: async () => {
await this.innerCache.set(params.Key, params.Body);

return {
$response: {} as Response<S3.Types.PutObjectOutput, AWSError>,
};
},
};
}
}

describe("S3Cache", () => {

it("should fetch when ttl is 0", async () => {
const cache = new S3Cache({ bucket: randomStr(), s3: new S3ClientStub() });
let fetched = false;
const result = await cache.getOrFetch(
"key",
async () => {
fetched = true;

return 0;
},
true,
0);

expect(result).equal(0);
expect(fetched).be.true;
});

it("should fetch and cache", async () => {
const cache = new S3Cache({ bucket: randomStr(), s3: new S3ClientStub() });
let fetched = 0;
let result = await cache.getOrFetch("key", async () => {
++fetched;

return 0; });

expect(result).equal(0);
expect(fetched).equal(1);
result = await cache.getOrFetch("key", async () => {
++fetched;

return 0; });

expect(result).equal(0);
expect(fetched).equal(1);
});

it("should fetch when useCache is false.", async () => {
const cache = new S3Cache({ bucket: randomStr(), s3: new S3ClientStub() });
let fetched = 0;
let result = await cache.getOrFetch("key", async () => {
++fetched;

return 0; });

expect(result).equal(0);
expect(fetched).equal(1);
result = await cache.getOrFetch(
"key",
async () => {
++fetched;

return 0; },
false);

expect(result).equal(0);
expect(fetched).equal(2);
});

});
3 changes: 2 additions & 1 deletion test/unit/services/aws/key-value-repository-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { AWSError, Response, S3 } from "aws-sdk";
import { expect } from "chai";
import * as HttpStatusCodes from "http-status-codes";
import { randomStr } from "../../../../src/core/utils";
import { S3Client, S3KeyValueRepository } from "../../../../src/services/aws/key-value-repository";
import { S3KeyValueRepository } from "../../../../src/services/aws/key-value-repository";
import { S3Client } from "../../../../src/services/aws/s3-client";
import { HealthCheckStatus } from "../../../../src/services/health-check";

class S3ClientStub implements S3Client {
Expand Down

0 comments on commit 891533c

Please sign in to comment.