-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
d661db7
commit 891533c
Showing
6 changed files
with
281 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>>; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
|
||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters