From a7b297e449cfdb54df71f5f1818d047220da2f56 Mon Sep 17 00:00:00 2001 From: Victor Quinn Date: Wed, 9 Oct 2024 16:16:05 -0400 Subject: [PATCH] Now supporting multiple providers with automatic fallback --- README.md | 275 +++++++++++++++++++++++-------- package.json | 2 +- src/index.test.ts | 230 +++++++++++++++++++++----- src/index.ts | 23 +-- src/providers/providerFactory.ts | 1 - src/weatherService.test.ts | 103 +++++++++--- src/weatherService.ts | 90 +++++++--- 7 files changed, 552 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index cc5318b..3c015f5 100644 --- a/README.md +++ b/README.md @@ -4,150 +4,283 @@ An awesome TypeScript weather client for fetching weather data from various weather providers. -## What makes it "plus"? +## What Makes It "Plus"? -* Support for various weather providers: - * [National Weather Service](https://weather-gov.github.io/api/) - * [OpenWeather](https://openweathermap.org/api) - * [Tomorrow](https://www.tomorrow.io/) (coming soon!) - * [WeatherKit](https://developer.apple.com/weatherkit/) (coming soon!) -* A clean and standardized API for fetching weather data regardless of the weather provider -* Baked in support for caching with [Redis](https://redis.io/) -* Built with TypeScript so it includes a type-safe API out of the box +- **Multiple Weather Providers with Fallback Support**: Seamlessly switch between weather providers and specify an ordered list for fallback. + - [National Weather Service](https://weather-gov.github.io/api/) + - [OpenWeather](https://openweathermap.org/api) + - [Tomorrow.io](https://www.tomorrow.io/) (coming soon!) + - [WeatherKit](https://developer.apple.com/weatherkit/) (coming soon!) +- **Clean and Standardized API**: Fetch weather data using a consistent interface, regardless of the underlying provider. +- **Built-in Caching**: Supports in-memory and Redis caching to optimize API usage and reduce latency. +- **TypeScript Support**: Enjoy a type-safe API out of the box for better development experience. + +## Installation + +Install the package using npm or yarn: + +```bash +npm install weather-plus + +or + +yarn add weather-plus +``` ## Usage -First import the library into your project: + +First, import the library into your project: + +import WeatherPlus from 'weather-plus'; + +or with CommonJS: + +const WeatherPlus = require('weather-plus').default; + +### Basic Usage + +Instantiate the WeatherPlus class with default options: ```ts -import { WeatherPlus } from 'weather-plus'; +const weatherPlus = new WeatherPlus(); ``` -or with CommonJS: +Fetch weather data: ```ts -const { WeatherPlus } = require('weather-plus'); +const weather = await weatherPlus.getWeather(40.748817, -73.985428); // Coordinates for New York City +console.log(weather); ``` -and then instantiate it +### Using Multiple Providers with Fallback + +You can specify an ordered list of providers for fallback. If the first provider fails (e.g., due to unsupported location), the next provider in the list will be tried. ```ts -const weatherPlus = new WeatherPlus(); +const weatherPlus = new WeatherPlus({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', // Replace with your actual API key + }, +}); ``` -and then use it +### Example with Providers and API Keys ```ts -const weather = await weatherPlus.getWeather(40.748020, -73.992400) -console.log(weather) +const weatherPlus = new WeatherPlus({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, + cacheTTL: 600, // Optional: Cache TTL in seconds (default is 300 seconds) +}); ``` -## Providers +Fetch weather data: + +```ts +const weather = await weatherPlus.getWeather(51.5074, -0.1278); // Coordinates for London +console.log(weather); +``` -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. +### Providers -If you provide no provider, it will default to using the National Weather Service. +One of the main benefits of this library is the ability to seamlessly switch between weather providers while maintaining a consistent API. This is particularly useful for: -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 + • Fallback Mechanism: Use a free provider by default and fallback to a paid provider if necessary. + • Coverage: Some providers may not support certain locations; having multiple providers ensures broader coverage. + • Cost Optimization: Reduce costs by prioritizing free or cheaper providers. -To use a different provider, you can pass in a string key for the provider to the constructor. For example, to use OpenWeather: +Available Providers + +- 'nws' - National Weather Service + - Notes: + - Free and doesn’t require an API key. + - Only supports locations within the United States. + - Rate-limited to 5 requests per second and 300 requests per minute. +- 'openweather' - OpenWeather + - Requires an API key. +- 'tomorrow.io' - Tomorrow.io (coming soon!) +- 'weatherkit' - Apple WeatherKit (coming soon!) + +### Specifying Providers + +You can specify the providers in order of preference: ```ts const weatherPlus = new WeatherPlus({ - provider: "openweather", - apiKey: "your-openweather-api-key", + providers: ['nws', 'openweather'], + apiKeys: { + openweather: '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!) +### API Keys -## Built-in caching +Some providers require API keys. Provide them using the apiKeys object, mapping provider names to their respective API keys. -There are multiple ways to use caching with this library. +```ts +const weatherPlus = new WeatherPlus({ + providers: ['openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, +}); +``` -Out of the box, if no redis client is provided, it will use an in-memory cache. +### Built-in Caching + +To optimize API usage and reduce latency, weather-plus includes built-in caching mechanisms. + +#### In-Memory Cache + +By default, if no Redis client is provided, the library uses 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. +Note: The in-memory cache is not shared across different instances or servers. It’s suitable for development and testing but not recommended for production environments where you have multiple server instances. + +#### Redis Cache -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. +For production use, it’s recommended to use a Redis cache, which allows sharing cached data across different instances of your application. ```ts -// With a redis client -const redisClient = redis.createClient(); +import { createClient } from 'redis'; + +const redisClient = createClient(); +await redisClient.connect(); + const weatherPlus = new WeatherPlus({ - redisClient, + redisClient, }); ``` -## Geohash +### Cache Time-to-Live (TTL) -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. +You can customize the cache TTL (time-to-live) in seconds. The default TTL is 300 seconds (5 minutes). -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`. +```ts +const weatherPlus = new WeatherPlus({ + cacheTTL: 600, // Cache data for 10 minutes +}); +``` + +### Geohash Precision -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. +The library uses geohashing to cache weather data for nearby locations efficiently. Geohashing converts latitude and longitude into a short alphanumeric string, representing an area on the Earth’s surface. -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. +#### Default Geohash Precision -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. +By default, the geohash precision is set to 5, which corresponds to an area of approximately 4.9 km x 4.9 km. ```ts -// No geohash precision, defaults to 5 const weatherPlus = new WeatherPlus(); +``` + +#### Customizing Geohash Precision + +You can adjust the geohashPrecision to broaden or narrow the caching area. -// Geohash precision of 7 +```ts +// Broader caching area (less precise) const weatherPlus = new WeatherPlus({ - geohashPrecision: 7, + geohashPrecision: 3, // Approximately 156 km x 156 km }); -// Geohash precision of 12, effectively "opting out" of geohashing by choosing a precision that is so small it doesn't help with caching +// Narrower caching area (more precise) const weatherPlus = new WeatherPlus({ - geohashPrecision: 12, + geohashPrecision: 7, // Approximately 610 m x 610 m }); ``` -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. +Note: A lower precision value results in a larger area being considered the same location for caching purposes. Adjust according to your application’s requirements. -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. +### Error Handling -We have this option for those who want to broaden or narrow the caching area. +The library provides custom error classes to help you handle specific error scenarios gracefully. -## Cache TTL +#### InvalidProviderLocationError -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. +This error is thrown when a provider does not support the requested location. ```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 -}); +import { InvalidProviderLocationError } from 'weather-plus'; + +try { + const weather = await weatherPlus.getWeather(51.5074, -0.1278); // London coordinates +} catch (error) { + if (error instanceof InvalidProviderLocationError) { + // Handle the error (e.g., notify the user or log the issue) + } else { + // Handle other types of errors + } +} ``` -Note the ttl works both for the in-memory cache and for the redis cache. +### TypeScript Support -## Types +This library is built with TypeScript and includes type-safe interfaces for weather data and errors. -This library is built with TypeScript and includes type-safe interfaces for the weather data and errors. +#### Weather Data Interface -## License +```ts +interface IWeatherData { + temperature: { + value: number; + unit: string; + }; + humidity: { + value: number; + unit: string; + }; + dewPoint: { + value: number; + unit: string; + }; + conditions: { + value: string; + unit: string; + }; +} +``` -MIT +### Complete Example +```ts +import WeatherPlus from 'weather-plus'; +import { createClient } from 'redis'; + +(async () => { + const redisClient = createClient(); + await redisClient.connect(); + + const weatherPlus = new WeatherPlus({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, + redisClient, + geohashPrecision: 5, + cacheTTL: 600, // 10 minutes + }); + + try { + const weather = await weatherPlus.getWeather(51.5074, -0.1278); // London + console.log(weather); + } catch (error) { + console.error('Error fetching weather data:', error); + } finally { + await redisClient.disconnect(); + } +})(); +``` +## 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 +Lovingly crafted in NYC by [Victor Quinn](https://github.com/victorquinn) at [Texture](https://www.texturehq.com) diff --git a/package.json b/package.json index c2112b0..c8e1b6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weather-plus", - "version": "0.1.1", + "version": "1.0.0", "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/index.test.ts b/src/index.test.ts index 346de96..288f7db 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -14,7 +14,7 @@ jest.mock('redis', () => { set: mSet, })), mGet, - mSet + mSet, }; }); @@ -23,6 +23,7 @@ describe('WeatherPlus Library', () => { let redisMock: any; beforeAll(() => { + // Use the same axios instance that your providers use mock = new MockAdapter(axios); redisMock = require('redis'); }); @@ -36,68 +37,76 @@ describe('WeatherPlus Library', () => { expect(weatherPlus).toBeDefined(); }); - it('should throw an error if the provider is tomorrow.io', () => { - const initTomorrowIO = () => { - new WeatherPlus({ provider: 'tomorrow.io' }); + it('should throw an error if an unsupported provider is specified', () => { + // Use type assertion to bypass TypeScript error for testing purposes + const initUnsupportedProvider = () => { + new WeatherPlus({ providers: ['tomorrow.io' as any] }); }; - expect(initTomorrowIO).toThrow('Provider tomorrow.io is not supported yet'); + expect(initUnsupportedProvider).toThrow('Provider tomorrow.io is not supported yet'); }); - it('should throw an error if the provider is WeatherKit', () => { - const initWeatherKit = () => { - new WeatherPlus({ provider: 'weatherkit' }); + it('should throw an error if an unsupported provider is included in providers array', () => { + // Use type assertion to bypass TypeScript error for testing purposes + const initUnsupportedProvider = () => { + new WeatherPlus({ providers: ['nws', 'weatherkit' as any] }); }; - expect(initWeatherKit).toThrow('Provider weatherkit is not supported yet'); + expect(initUnsupportedProvider).toThrow('Provider weatherkit is not supported yet'); }); - it('should follow up the call with the forecast', async () => { + it('should get weather data using default provider (NWS)', async () => { const lat = 40.7128; const lng = -74.0060; const mockResponses = [ { properties: { - forecast: "https://api.weather.gov/gridpoints/OKX/33,37/forecast", - observationStations: "https://api.weather.gov/gridpoints/OKX/33,35/stations", - } + forecast: 'https://api.weather.gov/gridpoints/OKX/33,37/forecast', + observationStations: 'https://api.weather.gov/gridpoints/OKX/33,35/stations', + }, }, { - features: [{ - id: "https://api.weather.gov/stations/KNYC", - properties: { - "@id": "https://api.weather.gov/stations/KNYC", - stationIdentifier: "NYC", - name: "New York City, Central Park", - state: "NY", - stationId: "NYC", - } - }] + features: [ + { + id: 'https://api.weather.gov/stations/KNYC', + properties: { + '@id': 'https://api.weather.gov/stations/KNYC', + stationIdentifier: 'NYC', + name: 'New York City, Central Park', + state: 'NY', + stationId: 'NYC', + }, + }, + ], }, { properties: { dewpoint: { value: 20, - unitCode: "wmoUnit:degC", - qualityControl: "V", + unitCode: 'wmoUnit:degC', + qualityControl: 'V', }, relativeHumidity: { value: 50, - unitCode: "wmoUnit:percent", - qualityControl: "V", + unitCode: 'wmoUnit:percent', + qualityControl: 'V', }, temperature: { value: 20, - unitCode: "wmoUnit:degC", - qualityControl: "V", + unitCode: 'wmoUnit:degC', + qualityControl: 'V', }, - textDescription: "Sunny", - } + textDescription: 'Sunny', + }, }, - ] - mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, mockResponses[0]); - mock.onGet(`https://api.weather.gov/gridpoints/OKX/33,35/stations`).reply(200, mockResponses[1]); - mock.onGet(`https://api.weather.gov/stations/KNYC/observations/latest`).reply(200, mockResponses[2]); + ]; - //redisMock.mGet.mockResolvedValue(null); + // Mock NWS API responses + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, mockResponses[0]); + mock + .onGet('https://api.weather.gov/gridpoints/OKX/33,35/stations') + .reply(200, mockResponses[1]); + mock + .onGet('https://api.weather.gov/stations/KNYC/observations/latest') + .reply(200, mockResponses[2]); const weatherPlus = new WeatherPlus(); const response = await weatherPlus.getWeather(lat, lng); @@ -117,12 +126,155 @@ describe('WeatherPlus Library', () => { conditions: { value: 'Sunny', unit: 'string', - } - } + }, + }; expect(response).toEqual(expectedResponse); }); - // Add this test case + it('should fallback to the next provider if the first provider fails', async () => { + const lat = 51.5074; // London, UK + const lng = -0.1278; + + // Mock NWS to throw an error (NWS does not support locations outside the US) + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(500); + + // Mock OpenWeather API response + const mockOpenWeatherResponse = { + lat: lat, + lon: lng, + timezone: 'Europe/London', + timezone_offset: 0, + current: { + dt: 1618317040, + sunrise: 1618282134, + sunset: 1618333901, + temp: 15, + feels_like: 13, + pressure: 1019, + humidity: 82, + dew_point: 12, + uvi: 0.89, + clouds: 75, + visibility: 10000, + wind_speed: 5, + wind_deg: 200, + weather: [ + { + id: 500, + main: 'Rain', + description: 'light rain', + icon: '10d', + }, + ], + }, + }; + + // Mock OpenWeather API request + mock + .onGet('https://api.openweathermap.org/data/3.0/onecall') + .reply(200, mockOpenWeatherResponse); + + const weatherPlus = new WeatherPlus({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, + }); + + const response = await weatherPlus.getWeather(lat, lng); + + expect(response).toBeDefined(); + expect(response.temperature).toEqual({ value: 15, unit: 'C' }); + expect(response.humidity).toEqual({ value: 82, unit: 'percent' }); + expect(response.conditions).toEqual({ value: 'light rain', unit: 'string' }); + }); + + it('should throw an error if all providers fail', async () => { + const lat = 51.5074; // London, UK + const lng = -0.1278; + + // Mock NWS to throw an error + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(500); + // Mock OpenWeather to return 500 + mock + .onGet('https://api.openweathermap.org/data/3.0/onecall') + .reply(500); + + const weatherPlus = new WeatherPlus({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, + }); + + await expect(weatherPlus.getWeather(lat, lng)).rejects.toThrow( + 'Request failed with status code 500' + ); + }); + + it('should use in-memory cache when no redis client is provided', async () => { + const lat = 40.7128; + const lng = -74.0060; + const mockResponses = [ + { + properties: { + forecast: 'https://api.weather.gov/gridpoints/OKX/33,37/forecast', + observationStations: 'https://api.weather.gov/gridpoints/OKX/33,35/stations', + }, + }, + { + features: [ + { + id: 'https://api.weather.gov/stations/KNYC', + properties: { + '@id': 'https://api.weather.gov/stations/KNYC', + stationIdentifier: 'NYC', + name: 'New York City, Central Park', + state: 'NY', + stationId: 'NYC', + }, + }, + ], + }, + { + properties: { + dewpoint: { + value: 20, + unitCode: 'wmoUnit:degC', + qualityControl: 'V', + }, + relativeHumidity: { + value: 50, + unitCode: 'wmoUnit:percent', + qualityControl: 'V', + }, + temperature: { + value: 20, + unitCode: 'wmoUnit:degC', + qualityControl: 'V', + }, + textDescription: 'Sunny', + }, + }, + ]; + + // Mock NWS API responses + mock.onGet(`https://api.weather.gov/points/${lat},${lng}`).reply(200, mockResponses[0]); + mock + .onGet('https://api.weather.gov/gridpoints/OKX/33,35/stations') + .reply(200, mockResponses[1]); + mock + .onGet('https://api.weather.gov/stations/KNYC/observations/latest') + .reply(200, mockResponses[2]); + + const weatherPlus = new WeatherPlus(); + const response1 = await weatherPlus.getWeather(lat, lng); + const response2 = await weatherPlus.getWeather(lat, lng); + + expect(response1).toEqual(response2); + // The second call should use cached data + }); + it('should export InvalidProviderLocationError', () => { expect(InvalidProviderLocationError).toBeDefined(); const error = new InvalidProviderLocationError('Test error'); diff --git a/src/index.ts b/src/index.ts index d267f06..5faba4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,17 @@ import { WeatherService } from './weatherService'; -import { InvalidProviderLocationError } from './errors'; +import { InvalidProviderLocationError, ProviderNotSupportedError, WeatherProviderError } from './errors'; import { RedisClientType } from 'redis'; +// Define the options interface for WeatherPlus interface WeatherPlusOptions { - provider?: 'nws' | 'openweather' | 'tomorrow.io' | 'weatherkit'; - apiKey?: string; - redisClient?: RedisClientType; - geohashPrecision?: number; - cacheTTL?: number; + providers?: Array<'nws' | 'openweather'>; // Ordered list of providers for fallback + apiKeys?: { [provider: string]: string }; // Mapping of provider names to their API keys + redisClient?: RedisClientType; // Optional Redis client for caching + geohashPrecision?: number; // Optional geohash precision for caching + cacheTTL?: number; // Optional cache time-to-live in seconds } +// Main WeatherPlus class that users will interact with class WeatherPlus { private weatherService: WeatherService; @@ -17,17 +19,18 @@ class WeatherPlus { this.weatherService = new WeatherService({ redisClient: options.redisClient, geohashPrecision: options.geohashPrecision, - provider: options.provider || 'nws', - apiKey: options.apiKey, + providers: options.providers || ['nws'], // Default to NWS if no providers specified + apiKeys: options.apiKeys, cacheTTL: options.cacheTTL, }); } + // Public method to get weather data for a given latitude and longitude async getWeather(lat: number, lng: number) { return this.weatherService.getWeather(lat, lng); } } -export { WeatherService, InvalidProviderLocationError }; +export { WeatherService, InvalidProviderLocationError, ProviderNotSupportedError, WeatherProviderError }; export * from './interfaces'; -export default WeatherPlus; +export default WeatherPlus; \ No newline at end of file diff --git a/src/providers/providerFactory.ts b/src/providers/providerFactory.ts index a57c3cd..b57f97a 100644 --- a/src/providers/providerFactory.ts +++ b/src/providers/providerFactory.ts @@ -13,7 +13,6 @@ export class ProviderFactory { return new NWSProvider(); case 'openweather': return new OpenWeatherProvider(apiKey!); - // ... handle other providers ... default: throw new ProviderNotSupportedError( `Provider ${providerName} is not supported yet` diff --git a/src/weatherService.test.ts b/src/weatherService.test.ts index 41afd78..b1cb00a 100644 --- a/src/weatherService.test.ts +++ b/src/weatherService.test.ts @@ -1,6 +1,7 @@ import { WeatherService } from './weatherService'; import { InvalidProviderLocationError } from './errors'; import { NWSProvider } from './providers/nws/client'; +import { OpenWeatherProvider } from './providers/openweather/client'; jest.mock('./cache', () => { return { @@ -19,13 +20,17 @@ jest.mock('./providers/nws/client', () => { class MockNWSProvider extends originalModule.NWSProvider { async getWeather(lat: number, lng: number) { - const isInUS = lat >= 24.7433195 && lat <= 49.3457868 && lng >= -124.7844079 && lng <= -66.9513812; + const isInUS = + lat >= 24.7433195 && + lat <= 49.3457868 && + lng >= -124.7844079 && + lng <= -66.9513812; if (!isInUS) { throw new InvalidProviderLocationError( 'The NWS provider only supports locations within the United States.' ); } - return { lat, lng, weather: 'sunny' }; + return { provider: 'nws', lat, lng, weather: 'sunny' }; } } @@ -36,12 +41,28 @@ jest.mock('./providers/nws/client', () => { }; }); +jest.mock('./providers/openweather/client', () => { + const originalModule = jest.requireActual('./providers/openweather/client'); + + class MockOpenWeatherProvider extends originalModule.OpenWeatherProvider { + async getWeather(lat: number, lng: number) { + return { provider: 'openweather', lat, lng, weather: 'cloudy' }; + } + } + + return { + __esModule: true, + ...originalModule, + OpenWeatherProvider: MockOpenWeatherProvider, + }; +}); + describe('WeatherService', () => { let weatherService: WeatherService; beforeEach(() => { jest.clearAllMocks(); - weatherService = new WeatherService({ provider: 'nws' }); + weatherService = new WeatherService({ providers: ['nws'] }); }); it('should return weather data for a location inside the United States', async () => { @@ -50,29 +71,34 @@ describe('WeatherService', () => { const weather = await weatherService.getWeather(lat, lng); - expect(weather).toEqual({ lat, lng, weather: 'sunny' }); + expect(weather).toEqual({ provider: 'nws', lat, lng, weather: 'sunny' }); }); - it('should throw InvalidProviderLocationError for a location outside the United States', async () => { - const service = new WeatherService({ provider: 'nws' }); + it('should fallback to the next provider if the first provider does not support the location', async () => { + weatherService = new WeatherService({ + providers: ['nws', 'openweather'], + apiKeys: { + openweather: 'your-openweather-api-key', + }, + }); const lat = 51.5074; // London, UK const lng = -0.1278; - await expect(service.getWeather(lat, lng)).rejects.toThrow( - InvalidProviderLocationError - ); + const weather = await weatherService.getWeather(lat, lng); + + expect(weather).toEqual({ provider: 'openweather', lat, lng, weather: 'cloudy' }); }); - it('should not call NWS API for a location outside the United States', async () => { - const getWeatherSpy = jest.spyOn(NWSProvider.prototype, 'getWeather'); - const lat = -33.8688; // Sydney, Australia - const lng = 151.2093; + it('should throw InvalidProviderLocationError if no provider supports the location', async () => { + weatherService = new WeatherService({ + providers: ['nws'], + }); + const lat = 51.5074; // London, UK + const lng = -0.1278; await expect(weatherService.getWeather(lat, lng)).rejects.toThrow( InvalidProviderLocationError ); - - expect(getWeatherSpy).not.toHaveBeenCalled(); }); it('should handle invalid latitude or longitude', async () => { @@ -85,7 +111,9 @@ describe('WeatherService', () => { }); it('should use cached weather data if available', async () => { - const cacheGetMock = jest.fn().mockResolvedValue(JSON.stringify({ cached: true })); + const cacheGetMock = jest + .fn() + .mockResolvedValue(JSON.stringify({ cached: true })); const cacheSetMock = jest.fn(); // Replace cache methods with mocks @@ -102,51 +130,74 @@ describe('WeatherService', () => { expect(cacheSetMock).not.toHaveBeenCalled(); }); - // Add this test case it('should rethrow generic errors from provider.getWeather', async () => { const genericError = new Error('Generic provider error'); // Mock the provider's getWeather to throw a generic error - jest.spyOn(weatherService['provider'], 'getWeather').mockRejectedValue(genericError); + jest + .spyOn(weatherService['providers'][0], 'getWeather') + .mockRejectedValue(genericError); const lat = 38.8977; // Valid US location const lng = -77.0365; - await expect(weatherService.getWeather(lat, lng)).rejects.toThrow('Generic provider error'); + 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.'; + 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 }); + new WeatherService({ providers: ['nws'], geohashPrecision: 0 }); }).toThrow(invalidGeohashPrecisionErrorMessage); }); it('should throw an error when geohashPrecision is negative', () => { expect(() => { - new WeatherService({ provider: 'nws', geohashPrecision: -1 }); + new WeatherService({ providers: ['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 }); + new WeatherService({ providers: ['nws'], geohashPrecision: 21 }); }).toThrow(invalidGeohashPrecisionErrorMessage); }); it('should create WeatherService with a valid geohashPrecision', () => { expect(() => { - new WeatherService({ provider: 'nws', geohashPrecision: 7 }); + new WeatherService({ providers: ['nws'], geohashPrecision: 7 }); }).not.toThrow(); expect(() => { - new WeatherService({ provider: 'nws', geohashPrecision: 9 }); + new WeatherService({ providers: ['nws'], geohashPrecision: 9 }); }).not.toThrow(); }); it('should create WeatherService without specifying geohashPrecision', () => { expect(() => { - new WeatherService({ provider: 'nws' }); + new WeatherService({ providers: ['nws'] }); }).not.toThrow(); }); + + it('should throw an error if no providers are specified', () => { + expect(() => { + new WeatherService({ providers: [] }); + }).toThrow('At least one provider must be specified.'); + }); + + it('should throw an error if an unsupported provider is specified', () => { + // Use type assertion to bypass TypeScript error for testing purposes + expect(() => { + new WeatherService({ providers: ['unsupportedProvider' as any] }); + }).toThrow('Provider unsupportedProvider is not supported yet'); + }); + + it('should throw an error if API key is missing for a provider that requires it', () => { + expect(() => { + new WeatherService({ providers: ['openweather'] }); + }).toThrow('OpenWeather provider requires an API key.'); + }); }); \ No newline at end of file diff --git a/src/weatherService.ts b/src/weatherService.ts index d6d7f76..146a0a3 100644 --- a/src/weatherService.ts +++ b/src/weatherService.ts @@ -10,14 +10,16 @@ import { isLocationInUS } from './utils/locationUtils'; const log = debug('weather-plus'); +// Define the options interface for WeatherService interface WeatherServiceOptions { - redisClient?: RedisClientType; - provider: 'nws' | 'openweather' | 'tomorrow.io' | 'weatherkit'; - apiKey?: string; - geohashPrecision?: number; - cacheTTL?: number; + redisClient?: RedisClientType; // Optional Redis client for caching + providers: Array<'nws' | 'openweather'>; // Ordered list of providers for fallback + apiKeys?: { [provider: string]: string }; // Mapping of provider names to their API keys + geohashPrecision?: number; // Optional geohash precision for caching + cacheTTL?: number; // Optional cache time-to-live in seconds } +// Schema for validating latitude and longitude coordinates const CoordinatesSchema = z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), @@ -25,17 +27,36 @@ const CoordinatesSchema = z.object({ export class WeatherService { private cache: Cache; - private provider: IWeatherProvider; + private providers: IWeatherProvider[]; private geohashPrecision: number; constructor(options: WeatherServiceOptions) { log('Initializing WeatherService with options:', options); + + // Initialize caching mechanism this.cache = new Cache(options.redisClient, options.cacheTTL); - this.provider = ProviderFactory.createProvider(options.provider, options.apiKey); + // Ensure that at least one provider is specified + if (!options.providers || options.providers.length === 0) { + throw new Error('At least one provider must be specified.'); + } + + // Create instances of the specified providers + this.providers = options.providers.map((providerName) => { + const apiKey = options.apiKeys ? options.apiKeys[providerName] : undefined; + return ProviderFactory.createProvider(providerName, apiKey); + }); + + // Set geohash precision for caching; default to 5 if not specified 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.'); + 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 { @@ -43,35 +64,56 @@ export class WeatherService { } } + // Public method to get weather data for a given latitude and longitude public async getWeather(lat: number, lng: number) { + // Validate coordinates const validation = CoordinatesSchema.safeParse({ lat, lng }); if (!validation.success) { throw new Error('Invalid latitude or longitude'); } - if (!isLocationInUS(lat, lng) && this.provider.name === 'nws') { - throw new InvalidProviderLocationError('NWS provider only supports locations in the United States'); - } - log(`Getting weather for (${lat}, ${lng}) using provider ${this.provider.constructor.name}`); + // Generate geohash for caching purposes const locationGeohash = geohash.encode(lat, lng, this.geohashPrecision); + // Attempt to retrieve weather data from cache const cachedWeather = await this.cache.get(locationGeohash); if (cachedWeather) { + log('Cache hit for geohash:', locationGeohash); return JSON.parse(cachedWeather); } else { - try { - const weather = await this.provider.getWeather(lat, lng); - await this.cache.set(locationGeohash, JSON.stringify(weather)); - return weather; - } catch (error) { - if (error instanceof InvalidProviderLocationError) { - // Handle the specific error if needed - log('Invalid location for the selected provider:', error.message); - throw error; - } else { - throw error; + log('Cache miss for geohash:', locationGeohash); + let lastError: Error | null = null; + + // Iterate through providers in order of preference + for (const provider of this.providers) { + try { + log(`Trying provider ${provider.name} for (${lat}, ${lng})`); + + // Check if provider supports the given location (e.g., NWS only supports US locations) + if (provider.name === 'nws' && !isLocationInUS(lat, lng)) { + log(`Provider ${provider.name} does not support location (${lat}, ${lng})`); + throw new InvalidProviderLocationError( + `${provider.name} provider does not support the provided location.` + ); + } + + // Attempt to get weather data from the provider + const weather = await provider.getWeather(lat, lng); + + // Store the retrieved weather data in cache + await this.cache.set(locationGeohash, JSON.stringify(weather)); + + // Return the weather data + return weather; + } catch (error) { + log(`Error with provider ${provider.name}:`, error); + lastError = error as Error; + // Continue to the next provider in case of an error } } + + // If all providers fail, throw the last encountered error + throw lastError || new Error('Unable to retrieve weather data from any provider.'); } } } \ No newline at end of file