city: <City Name,Country Code>
or zip: <Zip Code>
, where it’s optional to include a space or neglect it in the former case.appId
query parameter in GET
Requests to their API. Due to that, you should keep in mind that any reference to “App ID” or “API Key” refers to the same thing.mkdir weather-sms && cd weather-sms npm init -y npm i axios npm i typescript @types/node --save-dev node_modules/.bin/tsc --init mkdir src env
tsc --init
above):{ "compilerOptions": { "target": "es5", "module": "commonjs", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
touch env/env.env
WEATHER_API_APP_ID=Your_OpenWeatherMap_App_ID ACCOUNT_SID=Your_Twilio_Account_SID AUTH_TOKEN=Your_Twilio_Auth_Token TWILIO_PHONE_NUMBER=Your_Twilio_Phone_Number //(Format +12345678900)
env-cmd
to make these variables available to the runtime.mkdir src/config touch src/config/appConfig.ts
/** * Global application-level configuration parameters. */ export const config = { weather: { api: { baseUrl: 'https://api.openweathermap.org/data/2.5/weather', appId: process.env.WEATHER_API_APP_ID as string, } }, twilio: { ACCOUNT_SID: process.env.ACCOUNT_SID as string, AUTH_TOKEN: process.env.AUTH_TOKEN as string, PHONE_NUMBER: process.env.TWILIO_PHONE_NUMBER as string } } as const;
const
are not truly constant - only the reference to the object is constant, which means you can’t reassign a const
object to another object, but you can manipulate the properties.as const
at the end.readonly
, and array literals will become readonly
tuples.const
) variable and assigning it to a literal value like 3.14
. The type of that variable will be the literal 3.14
since it can’t be changed. If you assigned the literal 3.14
to a variable declared via let
(i.e, a non-constant variable), the variable type will be widened from the literal type 3.14
to the number
type, since other numbers can be assigned and you’re not reduced to that literal.mkdir src/utils touch src/utils/conversions.ts
// Temperature export const celsiusToFahrenheit = (value: number) => ((value * (9/5)) + 32); export const kelvinToCelsius = (value: number) => (value - 273.15); // Speed export const metersPerSecondToMilesPerHour = (value: number) => (value * 2.237);
Immutable<T>
, which will allow you to force any arguments passed to methods to be readonly
(recursively). That is, the object specified as the generic T
will be effectively deeply frozen at design time./** * Forces a type to be immutable. */ export type Immutable<T> = { readonly [K in keyof T]: Immutable<T[K]> }
readonly
, by iterating through its properties until it reaches the end of the tree. It will look at each key of the object of type T
and make it readonly
, while also making the value of that key (the T[K]
part) readonly
too. To learn more about Recursive Types, visit the TypeScript Documentation.mkdir src/weather src/weather/api touch src/weather/api/getWeather.ts src/weather/api/parseWeatherDto.ts
/** * Represents a response DTO shape from the Weather API. */ export interface IWeatherResponseDTO { weather: Array<{ main: string, description: string }>, main: { temp: number, feels_like: number, pressure: number, humidity: number }, visibility: number, wind: { speed: number, deg: number, } }
weather
main
visibility
wind
weather
is an array containing the various weather conditions and a description for each. For example, rain, thunderstorms, fog, etc. These properties come straight from the OpenWeatherMap API documentation.... /** * Represents the two queryable location types. */ export enum LocationType { ZIP, CITY }
appId
query parameter, but for zip codes, you'll have to use the zip
query parameter. For other location types, including city names, you can use the q
query parameter.config
object you exported earlier.import { config } from '../../config/appConfig'; /** * Represents a response DTO shape from the Weather API. */ export interface IWeatherResponseDTO { ... } ...
makeUrl
:... /** * Constructs an API URL based on a specified target location and type. * * @param location * The target location to construct the URL against. * * @param locationType * The target location type to construct the URL against. */ function makeUrl(location: string, locationType: LocationType) { const BASE_URL = config.weather.api.baseUrl; const APP_ID = config.weather.api.appId; const partialUri = locationType === LocationType.ZIP ? `${BASE_URL}?zip=${location}` : `${BASE_URL}?q=${location}` // Full URL with query location and app ID. return `${partialUri}&appId=${APP_ID}`; }
makeUrl
function accepts the location
as a string, be it a zip code or city name, as well as a LocationType
enumeration, which is used to differentiate between the two (because just location
would be too ambiguous).const
variables. This helps to keep things clear. It then creates a partial URI dynamically based on the value of locationType
, and then appends the APP_ID
variable to the end of the string. This whole value then gets returned.queryApi
, and it will use the Axios HTTP Client to query the API. Add the following code beneath the rest of the code in the file:import axios from 'axios'; import { config } from '../../config/appConfig'; ... /** * Makes an API call to retrieve current weather information for a specified location. * * @param target * Target location to retrieve weather for. * * @param type * Target location type to retrieve weather for. */ async function queryApi(target: string, type: LocationType) { const response = await axios.get<IWeatherResponseDTO>(makeUrl(target, type)); return response.data as IWeatherResponseDTO; }
queryApi
function makes the GET
request to the weather API and returns the response. The reason this is separated into another function is just to abstract away the code that interfaces with Axios so that you can migrate away from Axios in the future without having to touch the rest of your code.makeUrl()
and queryApi()
functions are not exported. That’s what you want - they are functions private to this file because they are directly coupled to the Weather API as well as Axios.LocationType
enumeration is to be able to move away from the Weather API or Axios without having to refactor your business logic.import axios from 'axios'; import { config } from '../../config/appConfig'; import { parseWeatherDto } from './parseWeatherDto'; ... /** * Retrieves current weather information for a specified location. * * @param target * Target location to retrieve weather for. * * @param type * Target location type to retrieve weather for. */ export async function getWeather(target: string, type: LocationType) { const dto = await queryApi(target, type); return parseWeatherDto(dto); }
getWeather()
function is what will be used throughout the rest of your application to call the weather API. It also parses the DTO that comes back via the parseWeatherDto
function, which you will create next.parseWeatherDto
function, you want to take that entire DTO interface, make all the unit conversions, and then collapse it into one readable string to return to the user.import { Immutable } from '../../utils/types'; import { IWeatherResponseDTO } from './getWeather'; import { celsiusToFahrenheit, kelvinToCelsius, metersPerSecondToMilesPerHour } from '../../utils/conversions'; /** * Parses a Weather API Response DTO into a readable string. * * @param response * The Weather API Response DTO */ export function parseWeatherDto(response: Immutable<IWeatherResponseDTO>) { // Current temperature in both units. const tempC = kelvinToCelsius(response.main.temp); const tempF = celsiusToFahrenheit(tempC); // Feels like temperature in both units. const feelsLikeC = kelvinToCelsius(response.main.feels_like); const feelsLikeF = celsiusToFahrenheit(feelsLikeC); // Reduce conditions array to one string. const addListSuffix = (idx: number) => `${((idx + 1) !== response.weather.length) ? ', ' : ''}`; const conditions = response.weather.reduce( (a, c, i) => a + `${c.main} (${c.description})${addListSuffix(i)}`, '' ); // Wind speed in MPH const windSpeedMPS = response.wind.speed; const windSpeedMPH = metersPerSecondToMilesPerHour(windSpeedMPS); const lines = [ `Conditions: ${conditions}`, `Temperature: C: ${tempC.toFixed(2)}, F: ${tempF.toFixed(2)}`, `Feels Like: C: ${feelsLikeC.toFixed(2)}, F: ${feelsLikeF.toFixed(2)}`, `Pressure: ${response.main.pressure} hPa`, `Humidity: ${response.main.humidity}%`, `Visibility: ${response.visibility}`, `Wind: ${windSpeedMPH.toFixed(2)} mi/hr at ${response.wind.deg} degrees` ]; // Each on a new line. return lines.join('\n'); }
IWeatherResponseDTO
type, wrapped in the generic Immutable<T>
type, which, as described earlier, makes all fields readonly
to ensure you don’t accidentally change the data in the DTO (thus sending bad information to the user!).as any
- which is the reason why you should seldom mark types as any
since it defeats the purpose of TypeScript. Additionally, the static typing of TypeScript is available at design time and design time only, and falls away in the compiled JavaScript.addListSuffix
local function checks if it is correct to append a comma and a space based on where you are in the string. The conditions
variable gets filled with the resulting string.lines
, which represents every line you want to send to the user. You finally join
that array, appending a newline character in between each line, and return that readable string.POST
request when a user sends a text message to your Twilio phone number, requesting weather information for a given location.express
and its corresponding types, and create a new file inside the weather folder called weather.ts:npm i express twilio npm i @types/express --save-dev touch src/weather/weather.ts
getWeatherForSMS()
that accepts the Express request and response objects:import { Request, Response } from 'express'; export function getWeatherForSMS(req: Request, res: Response) { // ... }
req.body
. Twilio will place the content of the text message on a property called Body
. So, that means that a given user-specified location will exist on req.body.Body
.getWeather
function from earlier.MessagingResponse
provided by the Twilio package you installed above. Using that, you'll build the message, and then you'll respond back to Twilio with it, which will go ahead and execute the instructions you specified.export function echoResponse(req: Request, res: Response) { const twiml = new MessagingResponse(); twiml.message(req.body.Body); res.writeHead(200, { 'Content-Type': 'text/xml' }); res.end(twiml.toString()); }
Response
object injected in, and then a closure will be created with the function it returns.touch utils/makeResponder.ts
import MessagingResponse from 'twilio/lib/twiml/MessagingResponse'; import { Response } from 'express'; /** * Makes a responder function. */ export function makeResponder (res: Response) { const twiml = new MessagingResponse(); /** * Responds to the incoming message with the specified response. */ return (msg: string) => { twiml.message(msg); res.writeHead(200, { 'Content-Type': 'text/xml' }); res.end(twiml.toString()); }; }
res
object everywhere, you only need it once - in the factory that creates the function. Now, for example, in any given Webhook function, you can access the returned function as follows:// Assume inside a webhook with access to Express Req/Res const respond = makeResponder(res); respond('The message to the user.');
touch src/utils/validation.ts
import { LocationType } from './../weather/api/getWeather'; /** * Ensures the incoming request is valid. * * @param body * The body to validation. */ export function isValid(body: string): boolean { if (!body) return false; const processed = body.trim().toLowerCase(); console.log('Processed:', processed) const allowedValues = ['city:', 'city: ', 'zip:', 'zip: ']; let isValid = false; allowedValues.forEach(val => processed.includes(val) ? isValid = true : null) return isValid; } /** * Parses a location target and location type out of a valid body. * * @param body * The data target to parse. */ export function parseLocationAndGetType(body: string): [string, LocationType] { if (!isValid(body)) throw new Error('Body is invalid.'); const processed = body.trim().toLowerCase().replace(' ', ''); const locationType = processed.includes('city') ? LocationType.CITY : LocationType.ZIP; const location = processed.split(':')[1]; return [ location, locationType ]; }
isValid()
function checks that the message from the user contains a substring of city:
or zip:
. If it does not, the input is invalid, so it returns false. Otherwise, it returns true.parseLocationAndGetType()
function attempts to pull the location and location type out of an input that it knows is valid. It returns the user’s location followed by the LocationType
enum as a tuple, which is quite similar to React Hooks.getWeatherForSMS()
function as follows:import { Request, Response } from 'express'; import { getWeather } from './api/getWeather'; import { isValid, parseLocationAndGetType } from '../utils/validation'; import { makeResponder } from './../utils/makeResponder'; export async function getWeatherForSMS(req: Request, res: Response) { // Makes a function for handling responses. const respond = makeResponder(res); // Validate the body if (!req.body || !isValid(req.body.Body)) { return respond('The location was malformed.'); } // Parse the input const [location, locationType] = parseLocationAndGetType(req.body.Body); // Return the weather const weather = await getWeather(location, locationType); return respond(weather); }
try/catch
block to handle errors. I’m generally not a fan of try/catch repetition, however, so I like to abstract away error handling logic to an external function. Create one more file in the utils folder called withErrorHandling.ts:touch src/utils/withErrorHandling.ts
/** * Wraps a likely to fail operation within an error handler. * * @param responder * The responder function. * * @param f * The likely to fail async operation to perform. */ export async function withErrorHandling( responder: (msg: string) => any, f: () => Promise<void>, ) { try { return await f(); } catch (e) { return responder('Something went wrong. Please try again later.'); } }
f
that encapsulates the likely to fail operation. It attempts to run the operation and returns the result, but if it fails, it catches the error and responds with a message to the user.getWeatherForSMS()
function to reflect the highlighted lines:import { Request, Response } from 'express'; import { getWeather } from './api/getWeather'; import { isValid, parseLocationAndGetType } from '../utils/validation'; import { makeResponder } from './../utils/makeResponder'; import { withErrorHandling } from './../utils/withErrorHandling'; export async function getWeatherForSMS(req: Request, res: Response) { // Makes a function for handling responses. const respond = makeResponder(res); return withErrorHandling(respond, async () => { // Validate the body if (!req.body || !isValid(req.body.Body)) { return respond('The location was malformed.'); } // Parse the input const [location, locationType] = parseLocationAndGetType(req.body.Body); // Return the weather const weather = await getWeather(location, locationType); return respond(weather); }); }
POST
endpoint.touch src/server.ts
import express from 'express'; import { getWeatherForSMS } from './weather/weather'; const PORT = (process.env.PORT as unknown as number) || 3000; const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.post('/sms', getWeatherForSMS); app.listen(PORT, () => console.log(`Server is up on port ${PORT}`));
env-cmd
to notify the runtime of your environment variables and make them accessible to the application. To do that, first install the package:npm i env-cmd
{ "name": "weather-sms", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start:build": "tsc", "start:run": "env-cmd -f ./env/env.env node ./build/server.js", "start": "npm run start:build && npm run start:run" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@types/express": "^4.17.9", "typescript": "^4.1.2" }, "dependencies": { "@types/node": "^14.14.9", "axios": "^0.21.0", "env-cmd": "^10.1.0", "express": "^4.17.1", "twilio": "^3.52.0" } }
-f
flag to notify env-cmd
of your environment variable location file, which will become available to the application when built.npm start
, which is the start script created above. It handles building the app through the TypeScript Compiler and notifying the runtime of the environment variables.ngrok
to make it accessible to the outside world. Note the port number the app is running on - in my case, it’s 3000 - and then in a new terminal window or tab, run npx ngrok http 3000
(or whatever port number). POST
request to [ngrok URL]/sms in order to hit your web hook. Navigate to the Manage Phone Number page of the Twilio Console, click your phone number, and under Twilio Messaging, paste your URL as a Webhook for HTTP POST.