diff --git a/errors.ts b/errors.ts new file mode 100644 index 0000000..d73aa17 --- /dev/null +++ b/errors.ts @@ -0,0 +1,85 @@ +export enum ErrorCode { + /** No error occurred. This code should be included with all successful responses because the firmware expects some + * code to be present. + */ + NoError = 0, + + /** The watering scale could not be calculated due to a problem with the weather information. */ + BadWeatherData = 1, + /** Data for a full 24 hour period was not available. */ + InsufficientWeatherData = 10, + /** A necessary field was missing from weather data returned by the API. */ + MissingWeatherField = 11, + /** An HTTP or parsing error occurred when retrieving weather information. */ + WeatherApiError = 12, + + /** The specified location name could not be resolved. */ + LocationError = 2, + /** An HTTP or parsing error occurred when resolving the location. */ + LocationServiceApiError = 20, + /** No matches were found for the specified location name. */ + NoLocationFound = 21, + /** The location name was specified in an invalid format (e.g. a PWS ID). */ + InvalidLocationFormat = 22, + + /** An Error related to personal weather stations. */ + PwsError = 3, + /** The PWS ID did not use the correct format. */ + InvalidPwsId = 30, + /** The PWS API key did not use the correct format. */ + InvalidPwsApiKey = 31, + // TODO use this error code. + /** The PWS API returned an error because a bad API key was specified. */ + PwsAuthenticationError = 32, + /** A PWS was specified but the data for the specified AdjustmentMethod cannot be retrieved from a PWS. */ + PwsNotSupported = 33, + /** A PWS is required by the WeatherProvider but was not provided. */ + NoPwsProvided = 34, + + /** An error related to AdjustmentMethods or watering restrictions. */ + AdjustmentMethodError = 4, + /** The WeatherProvider is incompatible with the specified AdjustmentMethod. */ + UnsupportedAdjustmentMethod = 40, + /** An invalid AdjustmentMethod ID was specified. */ + InvalidAdjustmentMethod = 41, + + /** An error related to adjustment options (wto). */ + AdjustmentOptionsError = 5, + /** The adjustment options could not be parsed. */ + MalformedAdjustmentOptions = 50, + /** A required adjustment option was not provided. */ + MissingAdjustmentOption = 51, + + /** An error was not properly handled and assigned a more specific error code. */ + UnexpectedError = 99 +} + +/** An error with a numeric code that can be used to identify the type of error. */ +export class CodedError extends Error { + public readonly errCode: ErrorCode; + + public constructor( errCode: ErrorCode, message?: string ) { + super( message ); + // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf( this, CodedError.prototype ); + this.errCode = errCode; + } +} + +/** + * Returns a CodedError representing the specified error. This function can be used to ensure that errors caught in try-catch + * statements have an error code and do not contain any sensitive information in the error message. If `err` is a + * CodedError, the same object will be returned. If `err` is not a CodedError, it is assumed that the error wasn't + * properly handled, so a CodedError with a generic message and an "UnexpectedError" code will be returned. This ensures + * that the user will only be sent errors that were initially raised by the OpenSprinkler weather service and have + * had any sensitive information (like API keys) removed from the error message. + * @param err Any error caught in a try-catch statement. + * @return A CodedError representing the error that was passed to the function. + */ +export function makeCodedError( err: any ): CodedError { + if ( err instanceof CodedError ) { + return err; + } else { + return new CodedError( ErrorCode.UnexpectedError ); + } +} diff --git a/routes/adjustmentMethods/AdjustmentMethod.ts b/routes/adjustmentMethods/AdjustmentMethod.ts index 9b37574..006b7d6 100644 --- a/routes/adjustmentMethods/AdjustmentMethod.ts +++ b/routes/adjustmentMethods/AdjustmentMethod.ts @@ -14,7 +14,7 @@ export interface AdjustmentMethod { * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if * the watering scale cannot be calculated. - * @throws An error message can be thrown if an error occurs while calculating the watering scale. + * @throws A CodedError may be thrown if an error occurs while calculating the watering scale. */ calculateWateringScale( adjustmentOptions: AdjustmentOptions, @@ -43,15 +43,6 @@ export interface AdjustmentMethodResponse { * watering. */ rainDelay?: number; - // TODO consider removing this field and breaking backwards compatibility to handle all errors consistently. - /** - * An message to send to the OS firmware to indicate that an error occurred while calculating the watering - * scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data. - * Older firmware versions will ignore this field (they will silently swallow the error and use the returned scale), - * but newer firmware versions may be able to alert the user that an error occurred and/or default to a - * user-configured watering scale instead of using the one returned by the AdjustmentMethod. - */ - errorMessage?: string; /** The data that was used to calculate the watering scale, or undefined if no data was used. */ wateringData: BaseWateringData; } diff --git a/routes/adjustmentMethods/EToAdjustmentMethod.ts b/routes/adjustmentMethods/EToAdjustmentMethod.ts index 03a7846..abfba8a 100644 --- a/routes/adjustmentMethods/EToAdjustmentMethod.ts +++ b/routes/adjustmentMethods/EToAdjustmentMethod.ts @@ -3,6 +3,7 @@ import * as moment from "moment"; import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; import { BaseWateringData, GeoCoordinates, PWS } from "../../types"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; +import { CodedError, ErrorCode } from "../../errors"; /** @@ -17,7 +18,7 @@ async function calculateEToWateringScale( ): Promise< AdjustmentMethodResponse > { if ( pws ) { - throw "ETo adjustment method does not support personal weather stations through WUnderground."; + throw new CodedError( ErrorCode.PwsNotSupported ); } // Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future. @@ -30,7 +31,7 @@ async function calculateEToWateringScale( } */ - // This will throw an error message if ETo data cannot be retrieved. + // This will throw a CodedError if ETo data cannot be retrieved. const etoData: EToData = await weatherProvider.getEToData( coordinates ); let baseETo: number; @@ -40,7 +41,7 @@ async function calculateEToWateringScale( if ( adjustmentOptions && "baseETo" in adjustmentOptions ) { baseETo = adjustmentOptions.baseETo } else { - throw "A baseline potential ETo must be provided."; + throw new CodedError( ErrorCode.MissingAdjustmentOption ); } if ( adjustmentOptions && "elevation" in adjustmentOptions ) { diff --git a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts index e8fea63..59375e1 100644 --- a/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts +++ b/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts @@ -2,6 +2,7 @@ import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from ". import { GeoCoordinates, PWS, ZimmermanWateringData } from "../../types"; import { validateValues } from "../weather"; import { WeatherProvider } from "../weatherProviders/WeatherProvider"; +import { CodedError, ErrorCode } from "../../errors"; /** @@ -38,12 +39,7 @@ async function calculateZimmermanWateringScale( // Check to make sure valid data exists for all factors if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) { // Default to a scale of 100% if fields are missing. - return { - scale: 100, - rawData: rawData, - errorMessage: "Necessary field(s) were missing from ZimmermanWateringData.", - wateringData: wateringData - }; + throw new CodedError( ErrorCode.MissingWeatherField ); } let humidityBase = 30, tempBase = 70, precipBase = 0; diff --git a/routes/weather.ts b/routes/weather.ts index fb2ffc7..eb5b192 100644 --- a/routes/weather.ts +++ b/routes/weather.ts @@ -13,6 +13,8 @@ import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod"; import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod"; import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod"; import EToAdjustmentMethod from "./adjustmentMethods/EToAdjustmentMethod"; +import { CodedError, ErrorCode, makeCodedError } from "../errors"; + const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )(); const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )(); @@ -39,16 +41,16 @@ const cache = new WateringScaleCache(); * Resolves a location description to geographic coordinates. * @param location A partial zip/city/country or a coordinate pair. * @return A promise that will be resolved with the coordinates of the best match for the specified location, or - * rejected with an error message if unable to resolve the location. + * rejected with a CodedError if unable to resolve the location. */ export async function resolveCoordinates( location: string ): Promise< GeoCoordinates > { if ( !location ) { - throw "No location specified"; + throw new CodedError( ErrorCode.InvalidLocationFormat ); } if ( filters.pws.test( location ) ) { - throw "PWS ID must be specified in the pws parameter."; + throw new CodedError( ErrorCode.InvalidLocationFormat ); } else if ( filters.gps.test( location ) ) { const split: string[] = location.split( "," ); return [ parseFloat( split[ 0 ] ), parseFloat( split[ 1 ] ) ]; @@ -62,7 +64,7 @@ export async function resolveCoordinates( location: string ): Promise< GeoCoordi data = await httpJSONRequest( url ); } catch (err) { // If the request fails, indicate no data was found. - throw "An API error occurred while attempting to resolve location"; + throw new CodedError( ErrorCode.LocationServiceApiError ); } // Check if the data is valid @@ -73,7 +75,7 @@ export async function resolveCoordinates( location: string ): Promise< GeoCoordi } else { // Otherwise, indicate no data was found - throw "No match found for specified location"; + throw new CodedError( ErrorCode.NoLocationFound ); } } } @@ -194,7 +196,7 @@ export const getWateringData = async function( req: express.Request, res: expres remoteAddress = remoteAddress.split( "," )[ 0 ]; if ( !adjustmentMethod ) { - res.send( "Error: Unknown AdjustmentMethod ID" ); + sendWateringError( res, new CodedError( ErrorCode.InvalidAdjustmentMethod )); return; } @@ -207,9 +209,8 @@ export const getWateringData = async function( req: express.Request, res: expres // Reconstruct JSON string from deformed controller output adjustmentOptions = JSON.parse( "{" + adjustmentOptionsString + "}" ); } catch ( err ) { - - // If the JSON is not valid then abort the claculation - res.send(`Error: Unable to parse options (${err})`); + // If the JSON is not valid then abort the calculation + sendWateringError( res, new CodedError( ErrorCode.MalformedAdjustmentOptions ) ); return; } @@ -217,8 +218,8 @@ export const getWateringData = async function( req: express.Request, res: expres let coordinates: GeoCoordinates; try { coordinates = await resolveCoordinates( location ); - } catch (err) { - res.send(`Error: Unable to resolve location (${err})`); + } catch ( err ) { + sendWateringError( res, makeCodedError( err ) ); return; } @@ -235,11 +236,11 @@ export const getWateringData = async function( req: express.Request, res: expres // Make sure that the PWS ID and API key look valid. if ( !pwsId ) { - res.send("Error: PWS ID does not appear to be valid."); + sendWateringError( res, new CodedError( ErrorCode.InvalidPwsId ) ); return; } if ( !apiKey ) { - res.send("Error: PWS API key does not appear to be valid."); + sendWateringError( res, new CodedError( ErrorCode.InvalidPwsApiKey ) ); return; } @@ -256,7 +257,7 @@ export const getWateringData = async function( req: express.Request, res: expres sunset: timeData.sunset, eip: ipToInt( remoteAddress ), rawData: undefined, - error: undefined + errCode: 0 }; let cachedScale: CachedScale; @@ -277,22 +278,11 @@ export const getWateringData = async function( req: express.Request, res: expres adjustmentOptions, coordinates, weatherProvider, pws ); } catch ( err ) { - if ( typeof err != "string" ) { - /* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are - missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error - thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message - since it may contain sensitive information. */ - res.send( "Error: an unexpected error occurred." ); - console.error( `An unexpected error occurred for ${ req.url }: `, err ); - } else { - res.send( "Error: " + err ); - } - + sendWateringError( res, makeCodedError( err ) ); return; } data.scale = adjustmentMethodResponse.scale; - data.error = adjustmentMethodResponse.errorMessage; data.rd = adjustmentMethodResponse.rainDelay; data.rawData = adjustmentMethodResponse.rawData; @@ -303,7 +293,7 @@ export const getWateringData = async function( req: express.Request, res: expres try { wateringData = await weatherProvider.getWateringData( coordinates ); } catch ( err ) { - res.send( "Error: " + err ); + sendWateringError( res, makeCodedError( err ) ); return; } } @@ -315,7 +305,7 @@ export const getWateringData = async function( req: express.Request, res: expres } // Cache the watering scale if caching is enabled and no error occurred. - if ( weatherProvider.shouldCacheWateringScale() && !data.error ) { + if ( weatherProvider.shouldCacheWateringScale() ) { cache.storeWateringScale( req.params[ 0 ], coordinates, pws, adjustmentOptions, { scale: data.scale, rawData: data.rawData, @@ -324,8 +314,31 @@ export const getWateringData = async function( req: express.Request, res: expres } } - // Return the response to the client in the requested format - if ( outputFormat === "json" ) { + sendWateringData( res, data, outputFormat === "json" ); +}; + +/** + * Sends a response to a watering scale request with an error code and a default watering scale of 100%. + * @param res The Express Response object to send the response through. + * @param error The error code to send in the response body. + * @param useJson Indicates if the response body should use a JSON format instead of a format similar to URL query strings. + */ +function sendWateringError( res: express.Response, error: CodedError, useJson: boolean = false ) { + if ( error.errCode === ErrorCode.UnexpectedError ) { + console.error( `An unexpected error occurred:`, error ); + } + + sendWateringData( res, { errCode: error.errCode, scale: 100 } ); +} + +/** + * Sends a response to an HTTP request with a 200 status code. + * @param res The Express Response object to send the response through. + * @param data An object containing key/value pairs that should be formatted in the response body. + * @param useJson Indicates if the response body should use a JSON format instead of a format similar to URL query strings. + */ +function sendWateringData( res: express.Response, data: object, useJson: boolean = false ) { + if ( useJson ) { res.json( data ); } else { // Return the data formatted as a URL query string. @@ -344,7 +357,7 @@ export const getWateringData = async function( req: express.Request, res: expres case "object": // Convert objects to JSON. value = JSON.stringify( value ); - // Fallthrough. + // Fallthrough. case "string": /* URL encode strings. Since the OS firmware uses a primitive version of query string parsing and decoding, only some characters need to be escaped and only spaces ("+" or "%20") will be decoded. */ @@ -356,7 +369,7 @@ export const getWateringData = async function( req: express.Request, res: expres } res.send( formatted ); } -}; +} /** * Makes an HTTP/HTTPS GET request to the specified URL and returns the response body. diff --git a/routes/weatherProviders/DarkSky.ts b/routes/weatherProviders/DarkSky.ts index bb24837..f8133b7 100644 --- a/routes/weatherProviders/DarkSky.ts +++ b/routes/weatherProviders/DarkSky.ts @@ -4,6 +4,7 @@ import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types" import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod"; +import { CodedError, ErrorCode } from "../../errors"; export default class DarkSkyWeatherProvider extends WeatherProvider { @@ -28,11 +29,11 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { yesterdayData = await httpJSONRequest( yesterdayUrl ); } catch ( err ) { console.error( "Error retrieving weather information from Dark Sky:", err ); - throw "An error occurred while retrieving weather information from Dark Sky." + throw new CodedError( ErrorCode.WeatherApiError ); } if ( !yesterdayData.hourly || !yesterdayData.hourly.data ) { - throw "Necessary field(s) were missing from weather information returned by Dark Sky."; + throw new CodedError( ErrorCode.MissingWeatherField ); } const samples = [ @@ -41,7 +42,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { // Fail if not enough data is available. if ( samples.length !== 24 ) { - throw "Insufficient data was returned by Dark Sky."; + throw new CodedError( ErrorCode.InsufficientWeatherData ); } const totals = { temp: 0, humidity: 0, precip: 0 }; @@ -122,7 +123,7 @@ export default class DarkSkyWeatherProvider extends WeatherProvider { try { historicData = await httpJSONRequest( historicUrl ); } catch (err) { - throw "An error occurred while retrieving weather information from Dark Sky." + throw new CodedError( ErrorCode.WeatherApiError ); } const cloudCoverInfo: CloudCoverInfo[] = historicData.hourly.data.map( ( hour ): CloudCoverInfo => { diff --git a/routes/weatherProviders/OWM.ts b/routes/weatherProviders/OWM.ts index cf109c6..9aa169c 100644 --- a/routes/weatherProviders/OWM.ts +++ b/routes/weatherProviders/OWM.ts @@ -3,6 +3,7 @@ import { httpJSONRequest } from "../weather"; import { WeatherProvider } from "./WeatherProvider"; import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod"; import * as moment from "moment"; +import { CodedError, ErrorCode } from "../../errors"; export default class OWMWeatherProvider extends WeatherProvider { @@ -25,12 +26,12 @@ export default class OWMWeatherProvider extends WeatherProvider { forecast = await httpJSONRequest( forecastUrl ); } catch ( err ) { console.error( "Error retrieving weather information from OWM:", err ); - throw "An error occurred while retrieving weather information from OWM." + throw new CodedError( ErrorCode.WeatherApiError ); } // Indicate watering data could not be retrieved if the forecast data is incomplete. if ( !forecast || !forecast.list ) { - throw "Necessary field(s) were missing from weather information returned by OWM."; + throw new CodedError( ErrorCode.MissingWeatherField ); } let totalTemp = 0, @@ -111,12 +112,12 @@ export default class OWMWeatherProvider extends WeatherProvider { forecast = await httpJSONRequest( forecastUrl ); } catch (err) { console.error( "Error retrieving ETo information from OWM:", err ); - throw "An error occurred while retrieving ETo information from OWM." + throw new CodedError( ErrorCode.WeatherApiError ); } // Indicate ETo data could not be retrieved if the forecast data is incomplete. if ( !forecast || !forecast.list || forecast.list.length < 8 ) { - throw "Insufficient data available from OWM." + throw new CodedError( ErrorCode.InsufficientWeatherData ); } // Take a sample over 24 hours. diff --git a/routes/weatherProviders/WUnderground.ts b/routes/weatherProviders/WUnderground.ts index 854af36..e83d41e 100644 --- a/routes/weatherProviders/WUnderground.ts +++ b/routes/weatherProviders/WUnderground.ts @@ -1,12 +1,13 @@ import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; import { httpJSONRequest } from "../weather"; +import { CodedError, ErrorCode } from "../../errors"; export default class WUnderground extends WeatherProvider { async getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { if ( !pws ) { - throw "WUnderground WeatherProvider requires a PWS to be specified."; + throw new CodedError( ErrorCode.NoPwsProvided ); } const url = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`; @@ -15,7 +16,7 @@ export default class WUnderground extends WeatherProvider { data = await httpJSONRequest( url ); } catch ( err ) { console.error( "Error retrieving weather information from WUnderground:", err ); - throw "An error occurred while retrieving weather information from WUnderground." + throw new CodedError( ErrorCode.WeatherApiError ); } // Take the 24 most recent observations. @@ -23,7 +24,7 @@ export default class WUnderground extends WeatherProvider { // Fail if not enough data is available. if ( samples.length !== 24 ) { - throw "Insufficient data was returned by WUnderground."; + throw new CodedError( ErrorCode.InsufficientWeatherData ); } const totals = { temp: 0, humidity: 0, precip: 0 }; diff --git a/routes/weatherProviders/WeatherProvider.ts b/routes/weatherProviders/WeatherProvider.ts index e837d6a..078d347 100644 --- a/routes/weatherProviders/WeatherProvider.ts +++ b/routes/weatherProviders/WeatherProvider.ts @@ -1,5 +1,6 @@ import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types"; import { EToData } from "../adjustmentMethods/EToAdjustmentMethod"; +import { CodedError, ErrorCode } from "../../errors"; export class WeatherProvider { /** @@ -8,11 +9,11 @@ export class WeatherProvider { * @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. * @return A Promise that will be resolved with the ZimmermanWateringData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the ZimmermanWateringData or the WeatherProvider - * does not support this method. + * or rejected with a CodedError if an error occurs while retrieving the ZimmermanWateringData (or the WeatherProvider + * does not support this method). */ getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< ZimmermanWateringData > { - throw "Selected WeatherProvider does not support getWateringData"; + throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod ); } /** @@ -29,12 +30,11 @@ export class WeatherProvider { /** * Retrieves the data necessary for calculating potential ETo. * @param coordinates The coordinates to retrieve the data for. - * @return A Promise that will be resolved with the EToData if it is successfully retrieved, - * or rejected with an error message if an error occurs while retrieving the EToData or the WeatherProvider does - * not support this method. + * @return A Promise that will be resolved with the EToData if it is successfully retrieved, or rejected with a + * CodedError if an error occurs while retrieving the EToData (or the WeatherProvider does not support this method). */ getEToData( coordinates: GeoCoordinates ): Promise< EToData > { - throw "Selected WeatherProvider does not support getEToData"; + throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod ); }; /** diff --git a/test/expected.json b/test/expected.json index fdcdf8f..16b6136 100644 --- a/test/expected.json +++ b/test/expected.json @@ -4,7 +4,8 @@ "tz": 32, "sunrise": 332, "sunset": 1203, - "eip": 2130706433 + "eip": 2130706433, + "errCode": 0 } }, "adjustment1": { @@ -19,7 +20,8 @@ "p": 1.09, "t": 70.8, "raining": 1 - } + }, + "errCode": 0 } } } diff --git a/tsconfig.json b/tsconfig.json index aaf0e43..ed9ea4d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "skipLibCheck": true }, "include": [ + "errors.ts", "server.ts", "types.ts", "routes/**/*"