diff --git a/README.md b/README.md index 3505f11..0799471 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) \ No newline at end of file diff --git a/package.json b/package.json index a495590..c2112b0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cache.test.ts b/src/cache.test.ts index fbf7862..d0ba93c 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -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(() => { @@ -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(); + }); }); }); \ No newline at end of file diff --git a/src/cache.ts b/src/cache.ts index cc1b8c1..f5f2565 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,16 +1,24 @@ import { RedisClientType } from 'redis'; +interface MemoryCacheItem { + value: string; + expiresAt: number; +} + export class Cache { private redisClient?: RedisClientType; - private memoryCache: Map = new Map(); + private memoryCache: Map = 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; } } @@ -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 { + async set(key: string, value: string, ttl?: number): Promise { + 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 }); } } } diff --git a/src/index.ts b/src/index.ts index b335ba1..d267f06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ interface WeatherPlusOptions { provider?: 'nws' | 'openweather' | 'tomorrow.io' | 'weatherkit'; apiKey?: string; redisClient?: RedisClientType; + geohashPrecision?: number; + cacheTTL?: number; } class WeatherPlus { @@ -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, }); } diff --git a/src/weatherService.test.ts b/src/weatherService.test.ts index 08b5e03..41afd78 100644 --- a/src/weatherService.test.ts +++ b/src/weatherService.test.ts @@ -113,4 +113,40 @@ describe('WeatherService', () => { await expect(weatherService.getWeather(lat, lng)).rejects.toThrow('Generic provider error'); }); + + const invalidGeohashPrecisionErrorMessage = 'Invalid geohashPrecision. It must be an integer greater than 0 and less than 20.'; + + it('should throw an error when geohashPrecision is zero', () => { + expect(() => { + new WeatherService({ provider: 'nws', geohashPrecision: 0 }); + }).toThrow(invalidGeohashPrecisionErrorMessage); + }); + + it('should throw an error when geohashPrecision is negative', () => { + expect(() => { + new WeatherService({ provider: 'nws', geohashPrecision: -1 }); + }).toThrow(invalidGeohashPrecisionErrorMessage); + }); + + it('should throw an error when geohashPrecision is greater than or equal to 20', () => { + expect(() => { + new WeatherService({ provider: 'nws', geohashPrecision: 21 }); + }).toThrow(invalidGeohashPrecisionErrorMessage); + }); + + it('should create WeatherService with a valid geohashPrecision', () => { + expect(() => { + new WeatherService({ provider: 'nws', geohashPrecision: 7 }); + }).not.toThrow(); + + expect(() => { + new WeatherService({ provider: 'nws', geohashPrecision: 9 }); + }).not.toThrow(); + }); + + it('should create WeatherService without specifying geohashPrecision', () => { + expect(() => { + new WeatherService({ provider: 'nws' }); + }).not.toThrow(); + }); }); \ No newline at end of file diff --git a/src/weatherService.ts b/src/weatherService.ts index 7a14317..d6d7f76 100644 --- a/src/weatherService.ts +++ b/src/weatherService.ts @@ -14,6 +14,8 @@ interface WeatherServiceOptions { redisClient?: RedisClientType; provider: 'nws' | 'openweather' | 'tomorrow.io' | 'weatherkit'; apiKey?: string; + geohashPrecision?: number; + cacheTTL?: number; } const CoordinatesSchema = z.object({ @@ -24,11 +26,21 @@ const CoordinatesSchema = z.object({ export class WeatherService { private cache: Cache; private provider: IWeatherProvider; + private geohashPrecision: number; constructor(options: WeatherServiceOptions) { log('Initializing WeatherService with options:', options); - this.cache = new Cache(options.redisClient); + this.cache = new Cache(options.redisClient, options.cacheTTL); this.provider = ProviderFactory.createProvider(options.provider, options.apiKey); + + if (options.geohashPrecision !== undefined) { + if (!Number.isInteger(options.geohashPrecision) || options.geohashPrecision <= 0 || options.geohashPrecision >= 20) { + throw new Error('Invalid geohashPrecision. It must be an integer greater than 0 and less than 20.'); + } + this.geohashPrecision = options.geohashPrecision; + } else { + this.geohashPrecision = 5; + } } public async getWeather(lat: number, lng: number) { @@ -41,8 +53,7 @@ export class WeatherService { } log(`Getting weather for (${lat}, ${lng}) using provider ${this.provider.constructor.name}`); - const precision = 5; // or desired precision - const locationGeohash = geohash.encode(lat, lng, precision); + const locationGeohash = geohash.encode(lat, lng, this.geohashPrecision); const cachedWeather = await this.cache.get(locationGeohash); if (cachedWeather) { @@ -50,7 +61,7 @@ export class WeatherService { } else { try { const weather = await this.provider.getWeather(lat, lng); - await this.cache.set(locationGeohash, JSON.stringify(weather), 300); // Cache for 5 mins + await this.cache.set(locationGeohash, JSON.stringify(weather)); return weather; } catch (error) { if (error instanceof InvalidProviderLocationError) {