Skip to content

Commit

Permalink
Merge pull request #15 from TextureHQ/victor/increase-configurability
Browse files Browse the repository at this point in the history
Increase configurability
  • Loading branch information
victorquinn authored Oct 9, 2024
2 parents 9292c0b + 5c9401d commit fce53f0
Show file tree
Hide file tree
Showing 7 changed files with 311 additions and 17 deletions.
122 changes: 118 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Weather Plus

![npm](https://img.shields.io/npm/v/weather-plus) | ![Codecov](https://img.shields.io/codecov/c/github/texturehq/weather-plus)

An awesome TypeScript weather client for fetching weather data from various weather providers.

Expand All @@ -16,24 +17,137 @@ An awesome TypeScript weather client for fetching weather data from various weat

## Usage
First import the library into your project:
```

```ts
import { WeatherPlus } from 'weather-plus';
```

or with CommonJS:
```

```ts
const { WeatherPlus } = require('weather-plus');
```

and then instantiate it
```

```ts
const weatherPlus = new WeatherPlus();
```

and then use it
```

```ts
const weather = await weatherPlus.getWeather(40.748020, -73.992400)
console.log(weather)
```

## Providers

Part of the main benefit of this library is the ability to seamlessly switch between weather providers while maintaining a consistent API. You can use this to hit NWS for free and fallback to a paid provider if the user is in an area with limited NWS coverage.

If you provide no provider, it will default to using the National Weather Service.

NWS is used by default because it's free and doesn't require an API key. However, a few important caveats
* It only supports locations in the United States
* It is rate limited so if you make too many requests (more than 5 per second or 300 per minute) it will rate limit you

To use a different provider, you can pass in a string key for the provider to the constructor. For example, to use OpenWeather:

```ts
const weatherPlus = new WeatherPlus({
provider: "openweather",
apiKey: "your-openweather-api-key",
});
```

The following providers are available:
* `nws` - National Weather Service
* `openweather` - OpenWeather
* `tomorrow` - Tomorrow.io (coming soon!)
* `weatherkit` - Apple WeatherKit (coming soon!)

## Built-in caching

There are multiple ways to use caching with this library.

Out of the box, if no redis client is provided, it will use an in-memory cache.

```ts
// No redis client, uses in-memory cache
const weatherPlus = new WeatherPlus();
```

This cache is not persisted across sessions and is not recommended for production use and because it is in-memory, it will not help across containers or nodes since the cache is scoped to the instance of the application. However, it will help reduce the volume of API requests which helps with cost and rate limiting.

If you want to use a shared Redis cache across instances of your application, you can pass in a redis client instance which `weather-plus` will use to store and retrieve weather data.

```ts
// With a redis client
const redisClient = redis.createClient();
const weatherPlus = new WeatherPlus({
redisClient,
});
```

## Geohash

There is another layer of caching that is built in to the library. When you supply a raw lat/lng, we convert that to a geohash and use that as the key for the cache. This means that weather data for the same location (or a decent area around the point) will be cached across multiple requests.

If you are unfamiliar with geohashes, they are a way to represent a location using a base-32 string of latitude and longitude coordinates. For example, the geohash for an area around the Empire State Building is `dr5ru6`.

We use a geohash of 5 characters to represent a location. This means that the area covered by a geohash is roughly a 4.9km x 4.9km rectangle (3 miles x 3 miles) at the equator which means that any 2 points that are anywhere within that rectangle will have the same geohash which means that the weather data for those points will be cached together.

Given that weather data doesn't change much on the scale of a few kilometers, this can be a very effective way to reduce the number of API requests you make, especially if you have many requests near the same location.

Geohashes are on always but you can alter the precision. If you would like to functionally "opt out" of them you can provide a precision like `geohashPrecision: 12` in the options. This will generate a geohash with a precision of 12 which means that the area covered by a geohash is roughly a 37.2mm x 18.6mm rectangle (1.46 inches x 0.73 inches) which is so specific as to not be useful for caching weather data across requests for near locations.

```ts
// No geohash precision, defaults to 5
const weatherPlus = new WeatherPlus();

// Geohash precision of 7
const weatherPlus = new WeatherPlus({
geohashPrecision: 7,
});

// Geohash precision of 12, effectively "opting out" of geohashing by choosing a precision that is so small it doesn't help with caching
const weatherPlus = new WeatherPlus({
geohashPrecision: 12,
});
```

Note if you "opt out" of geohashing by providing a precision of 12 or more, the library will not be able to cache weather data across requests for near locations so your API request rate limits will be higher.

You can also opt to broaden caching by providing a `geohashPrecision` of less than 5. This will cause the library to cache weather data for a larger area which can help reduce the number of API requests but will cause less specific caching. For example, a `geohashPrecision` of 3 will cache weather data for an area roughly 156km x 156km (97 miles x 97 miles) which is a decent sized area but probably good enough for caching temperature within some degree of accuracy in some use cases.

We have this option for those who want to broaden or narrow the caching area.

## Cache TTL

You can also set a custom TTL for the cache. This is the amount of time in seconds that the cache will store the weather data. If no TTL is provided, the cache will store the weather data for 5 minutes.

```ts
// No cache TTL, uses default of 300 seconds
const weatherPlus = new WeatherPlus();

// Cache TTL of 3600 seconds (1 hour)
const weatherPlus = new WeatherPlus({
redisClient,
cacheTTL: 3600, // 1 hour
});
```

Note the ttl works both for the in-memory cache and for the redis cache.

## Types

This library is built with TypeScript and includes type-safe interfaces for the weather data and errors.

## License

MIT




Lovingly crafted in NYC by [Victor Quinn](https://github.com/victorquinn) at [Texture](https://www.texturehq.com)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "weather-plus",
"version": "0.1.0",
"version": "0.1.1",
"description": "Weather Plus is a powerful wrapper around various Weather APIs that simplifies adding weather data to your application",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
Expand Down
115 changes: 115 additions & 0 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ describe('Cache', () => {
expect(mockRedisClient.set).toHaveBeenCalledWith('key', 'value', { EX: 300 });
});

// Test case for Redis cache with no TTL provided
it('should use default TTL when none is provided (Redis)', async () => {
const key = 'default-ttl-key';
const value = 'default-ttl-value';

await cache.set(key, value);

expect(mockRedisClient.set).toHaveBeenCalledWith(key, value, { EX: 300 }); // Default TTL is 300 seconds
});

it('should use the provided TTL when one is given (Redis)', async () => {
const key = 'custom-ttl-key';
const value = 'custom-ttl-value';
const ttl = 600; // 10 minutes

await cache.set(key, value, ttl);

expect(mockRedisClient.set).toHaveBeenCalledWith(key, value, { EX: ttl });
});

it('should use default TTL of 300 seconds when none is provided (Redis)', async () => {
const key = 'no-ttl-key';
const value = 'no-ttl-value';
const ttlTestCache = new Cache(mockRedisClient as RedisClientType, 100);

await ttlTestCache.set(key, value);

expect(mockRedisClient.set).toHaveBeenCalledWith(key, value, { EX: 100 }); // Provided default TTL is 100 seconds
});

// Additional tests for memory cache behavior
describe('Memory Cache Behavior', () => {
beforeEach(() => {
Expand All @@ -84,5 +114,90 @@ describe('Cache', () => {
});

// Since memory cache doesn't throw errors, we don't need error handling tests here

it('should use default TTL of 300 seconds when none is provided (Memory Cache)', async () => {
jest.useFakeTimers();

const key = 'default-ttl-key';
const value = 'default-ttl-value';

await cache.set(key, value);

// Advance time by 299 seconds
jest.advanceTimersByTime(299 * 1000);
let result = await cache.get(key);
expect(result).toEqual(value);

// Advance time by 2 more seconds (total 301 seconds)
jest.advanceTimersByTime(2 * 1000);
result = await cache.get(key);
expect(result).toBeNull();

jest.useRealTimers();
});

it('should use the provided TTL when one is given (Memory Cache)', async () => {
jest.useFakeTimers();

const key = 'custom-ttl-key';
const value = 'custom-ttl-value';
const ttl = 600; // 10 minutes

await cache.set(key, value, ttl);

// Advance time by 599 seconds
jest.advanceTimersByTime(599 * 1000);
let result = await cache.get(key);
expect(result).toEqual(value);

// Advance time by 2 more seconds (total 601 seconds)
jest.advanceTimersByTime(2 * 1000);
result = await cache.get(key);
expect(result).toBeNull();

jest.useRealTimers();
});

it('should use default TTL of 100 seconds when a default TTL is provided (Memory Cache)', async () => {
jest.useFakeTimers();
const ttlTestCache = new Cache(undefined, 100);

const key = 'no-ttl-key';
const value = 'no-ttl-value';

await ttlTestCache.set(key, value);

// Advance time by 99 seconds
jest.advanceTimersByTime(99 * 1000);
let result = await ttlTestCache.get(key);
expect(result).toEqual(value);

// Advance time by 2 more seconds (total 101 seconds)
jest.advanceTimersByTime(2 * 1000);
result = await ttlTestCache.get(key);
expect(result).toBeNull();

jest.useRealTimers();
});

it('should use default TTL of 300 seconds when no default TTL is provided (Memory Cache)', async () => {
jest.useFakeTimers();
const key = 'no-ttl-key';
const value = 'no-ttl-value';

await cache.set(key, value);

// Advance time by 299 seconds
jest.advanceTimersByTime(299 * 1000);
let result = await cache.get(key);
expect(result).toEqual(value);

// Advance time by 2 more seconds (total 301 seconds)
jest.advanceTimersByTime(2 * 1000);
result = await cache.get(key);
expect(result).toBeNull();

jest.useRealTimers();
});
});
});
28 changes: 21 additions & 7 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { RedisClientType } from 'redis';

interface MemoryCacheItem {
value: string;
expiresAt: number;
}

export class Cache {
private redisClient?: RedisClientType;
private memoryCache: Map<string, string> = new Map();
private memoryCache: Map<string, MemoryCacheItem> = new Map();
private cacheType: 'redis' | 'memory';
private defaultTTL: number;

constructor(client?: RedisClientType) {
constructor(client?: RedisClientType, ttl?: number) {
if (client) {
this.redisClient = client;
this.cacheType = 'redis';
this.defaultTTL = ttl ?? 300;
} else {
this.cacheType = 'memory';
this.defaultTTL = ttl ?? 300;
}
}

Expand All @@ -19,16 +27,22 @@ export class Cache {
const val = await this.redisClient.get(key);
return val === undefined ? null : val;
} else {
return this.memoryCache.get(key) || null;
const item = this.memoryCache.get(key);
if (item && item.expiresAt > Date.now()) {
return item.value;
}
this.memoryCache.delete(key);
return null;
}
}

async set(key: string, value: string, ttl: number): Promise<void> {
async set(key: string, value: string, ttl?: number): Promise<void> {
const expiresIn = ttl ?? this.defaultTTL;
if (this.cacheType === 'redis' && this.redisClient) {
await this.redisClient.set(key, value, { EX: ttl });
await this.redisClient.set(key, value, { EX: expiresIn });
} else {
this.memoryCache.set(key, value);
// Optionally handle TTL for memory cache if needed
const expiresAt = Date.now() + expiresIn * 1000;
this.memoryCache.set(key, { value, expiresAt });
}
}
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ interface WeatherPlusOptions {
provider?: 'nws' | 'openweather' | 'tomorrow.io' | 'weatherkit';
apiKey?: string;
redisClient?: RedisClientType;
geohashPrecision?: number;
cacheTTL?: number;
}

class WeatherPlus {
Expand All @@ -14,8 +16,10 @@ class WeatherPlus {
constructor(options: WeatherPlusOptions = {}) {
this.weatherService = new WeatherService({
redisClient: options.redisClient,
geohashPrecision: options.geohashPrecision,
provider: options.provider || 'nws',
apiKey: options.apiKey
apiKey: options.apiKey,
cacheTTL: options.cacheTTL,
});
}

Expand Down
Loading

0 comments on commit fce53f0

Please sign in to comment.