Ashley Sheridan​.co.uk

Creating a Weather Based Email Image Generator

Posted on

Tags:

Some years ago I built out a tool to generate dynamic images for email campaigns based on a variety of different parameters, like random selections from an Instagram gallery, animated gifs marking a countdown, and product images based on the weather in a given area. The latter of those is very simple to do with free APIs.

Contents

Concept

Before we start building anything, we have to decide how it should be built. In order to get the current weather for a location, we need something like the Open Weather API. I'll make the assumption here that we'll have access to the first half of the postcodes in the email campaign, and that they are all UK-based.

As we will be relying on a third-party API (Application Programming Interface), which has its own limits, we should consider the volume of requests we expect, and how we can reduce this number down to avoid hitting API limits.

If you have a look at the Open Weather Map API pricing you can see you'll get 1000 calls per day. Now, this is probably far too low for a typical email campaign, but we can make a few assumptions:

  1. For the purposes of this test, let's assume only a single country, the UK.
  2. The UK is split into 124 postcode areas, and the weather is not going to be drastically different across an entire postcode area.
  3. We can cache the result from the API for several hours, as the weather shouldn't drastically change much across the course of a single day.

Given this, we could allow for each postcode area to be requested against the Open Weather Map API about 8 times a day, which is more than we really need given our assumptions above.

We have missed one major point though, how many requests do we expect to getat once, or what does the overall usage peak at? We'll get to this a little later.

Creating the App Outline

The very first step is creating the new app with:

composer create-project laravel/laravel weather-image

After this, we'll need to create a new route, a controller to handle this route, and a service to fetch the weather from our API.

New Route

The route is fairly simple (for now) and looks like this:

Route::get('weather_img/{postcodeArea}', [ImageController::class, 'getImage']) ->where('postcodeArea', '[a-z]{1,2}') ->name('weather');

What we have here is a route that takes a postcode area as a parameter, validates that as being one or two letters with a regular expression (postcode areas are more restricted than that, but for this example it will suffice), and passes that through to the getImage() method of an ImageController class. Don't worry that it doesn't yet exist, we'll add that next.

Image Controller

The best way to create a new controller in Laravel is with the artisan commands:

php artisan make:controller ImageController

We then need to add the class method that will return our image:

public function getImage(string $postcodeArea) {}

Before we go populating this though, we need to think about what specifically we need to do:

  1. Get the weather for our postcode
  2. Get a local image that corresponds to that weather
  3. Return that image if it exists, or perhaps an error if it doesn't

We're going to need that service!

Building a Weather Service

Considering the things that we identified as requirements for the controller in the last step, we know we'll need a service class with at least one entry point method. I tend to prefer to use service classes for this kind of thing, but if you prefer a different approach, go with that.

Laravel doesn't have an artisan command for this, so I manaually create it using the same namespace pattern that the framework already uses, creating a class called WeatherService inside app/Service/, and give it this general outline:

<?php namespace App\Services; class WeatherService { public function getWeatherForPostcode(string $postcode): string {} }

Making the HTTP Request

The first thing is to fetch the current weather for a location, and we can use the Open Weather Map weather endpoint with the zip parameter for this:

https://api.openweathermap.org/data/2.5/weather?zip=[LOCATION]&appid=[YOUR_API_ID]

A simple request would look like this:

$response = Http::withHeaders([ 'Content-Type' => 'application/json', ])->get( 'https://api.openweathermap.org/data/2.5/weather', [ 'zip' => "{$postcode}1,gb", 'appid' => $apiKey, ] ); $weatherCode = json_decode($response->body())->weather[0]->id;

There are a couple of things to note here:

  • You need to set the Content-Type header, because otherwise Open Weather Map is not going to respond with a valid response, and the error message doesn't really explain what's going on!
  • The value of the zip parameter is the postcode area (2 letters), the digit 1, and then a comma and the letters 'gb'. The 1 just makes it a valid postcode half (and there's always at least 1 sub-region in a postcode area). The gb part just lets Open Weather Map know that it's a UK postcode.
  • The $apiKey is coming from a variable, rather than hard-coded, just because it's not the most secure practice to have API keys in code, especially if this code is ever made open source!
  • Finally, we get the weather code (a number) from the response body.

Handling Errors

Now, you might notice that there could be problems with this approach. What if the remote API is down, or what if the passed in postcode couldn't be found? Or one of many of the any other problems that could occur when making an HTTP request. The solution to that is to wrap the request in a try/catch block, and handle the error with some kind of sensible default:

try { $response = Http::withHeaders([ 'Content-Type' => 'application/json', ])->get( 'https://api.openweathermap.org/data/2.5/weather', [ 'zip' => "{$postcode}1,gb", 'appid' => $apiKey, ] ); $weatherCode = json_decode($response->body())->weather[0]->id; } catch (\Exception $e) { $weatherCode = 800; }

The only thing that needs explaining here is the value 800, which is the value used by Open Weather Map for clear weather (although, as it's the UK, perhaps a more sensible default would have been clouds!)

Mapping Weather Codes to a Weather String

Now, how do we turn the code into a string denoting the weather. There are a lot of different return codes that essentially map back to the same thing, like varying types of rain, or clouds, for example. This is why we can't just use the return string from the weather type, because we don't want to be handling 9 types of drizzle (light rain) and 10 types of rain as 19 separate weather types!

To deal with this, we can actually use a map of the codes and a method in our class to return the weather type string from a code:

private array $codeMap = [ 'storm' => [200, 201, 202, 210, 211, 212, 221, 230, 230, 232], 'rain' => [300, 301, 302, 310, 311, 312, 313, 314, 321, 500, 501, 502, 503, 504, 511, 520, 521, 522, 531], 'snow' => [600, 601, 602, 611, 612, 613, 615, 616, 620, 621, 622], 'atmosphere' => [701, 711, 721, 731, 741, 751, 761, 762, 771, 781], 'clear' => [800], 'clouds' => [801, 802, 803, 804], ]; private function getWeatherTypeFromCode(int $weatherCode): string { foreach ($this->codeMap as $weatherType => $codes) { if(in_array($weatherCode, $codes)) return $weatherType; } return 'clear'; }

The lookup method is fairly simple, and loops through the map. If we find the value in the map, we return the weather string, and stop. If we can't find it after all of that, we return a default weather type. At all points in our code, we are being defensive, and falling back on sensible default values if we ever get input that we don't expect, giving the code some resilience.

This lookup is then called at the bottom of our getWeatherForPostcode() method:

return $this->getWeatherTypeFromCode($weatherCode);

Caching the HTTP Request

While this works, the code will currently call the remote API every time the getWeatherForPostcode() method is called. We can prevent this by caching the result, and using the cache if it exists instead of making a call to the API.

First, we need to define a key that we will use to cache the result. This key should contain the postcode we're looking up so that multiple postcodes can all have their own cached values:

$cacheKey = "weather_$postcode";

Then, wrap our API try/catch block inside an if/else, checking the cache:

if(Cache::has($cacheKey)) { $weatherCode = Cache::get($cacheKey); } else { // try/catch API call block here }

The last step is to actually add our API response to the cache with a suitable cache duration, by adding this beneath the HTTP call:

Cache::put($cacheKey, $weatherCode, now()->addHours($this->cacheHours));

For the purposes of this, cache duration has been set to 12 as a private member variable of the class:

private int $cacheHours = 6;

Return an Image

The service is ready and working, so it can be used to determine an image to send back as the response:

I created some dummy images to use for different types of weather, and made a similar map and method to match an image name to the expected weather type responses in the ImageController. Our class now looks like this:

<?php namespace App\Http\Controllers; use App\Services\WeatherService; class ImageController extends Controller { private array $weatherImageMap = [ 'storm' => 'dark-and-stormy', 'rain' => 'tea', 'snow' => 'hot-chocolate', 'atmosphere' => 'hot-chocolate', 'clear' => 'beer', 'clouds' => 'tea', ]; private string $defaultImage = 'tea'; public function __construct(private WeatherService $weatherService) {} public function getImage(string $postcodeArea) { $weather = $this->weatherService->getWeatherForPostcode( strtolower($postcodeArea) ); $imageName = $this->getImageNameByWeatherType($weather); // image response will go here } private function getImageNameByWeatherType(string $weatherType): string { return in_array($weatherType, $this->weatherImageMap) ? $this->weatherImageMap[$weatherType] : $this->defaultImage; } }

Here are the things to note:

  • We have an array that maps weather types to image names (the images are named after drinks in this example), allowing us to re-use images for different conditions rather than the image be named for the weather directly.
  • There's a default image to use if no match can be found by the getImageNameByWeatherType() method. It's a good practice to allow for the unexpected.
  • The constructor has the weather service injected as an argument. We could have used facades for this instead, but I like the explicitness of doing it this way.

Image Response

The final piece of the puzzle is to return the image as a response. The recommended method to do this in Laravel is to use something like the intervention-image package, which allows you to generate an image from a file, and perform various actions on it, like adding watermarks, or resizing it.

For this demo, I think that would be a little overkill. I don't want to add anything to the image, and I can have my images in the size I want them by default. The only real benefit left to my is the code would be easier to test because of the facades. However, for now, I'm going with a more traditional approach using fpassthru():

$imagePath = public_path("img/$imageName.webp"); if(!file_exists($imagePath)) { abort(404, 'Image not found'); } header("Content-Type: image/webp"); header("Content-Length: " . filesize($imagePath)); $fh = fopen($imagePath, 'rb'); fpassthru($fh);

Here are the key things of note in this code:

  • We get the path to our image. This assumes that inside Laravels public directory there is another called img, and the images all reside in there in the Webp format (which has great support these days). Using the public_path() function, we ensure we get the right path, even if we have re-configured our Laravel installation to use a different public directory from its default.
  • If the file doesn't exist, we return an error. Up until now, we have been very defensive with our code, falling back to sane defaults, but if it's got all the way here, we probably want it to fail and let us know in the error logs!
  • We send back the right type and data length headers. While some browsers can work around these headers not being sent, we shouldn't rely on that when it's trivial to send this information.
  • We then open a file handle (in read-only binary mode, as the files are binary) and pass that data right back through as the response.
  • You may have noticed I didn't close the file handle manually. PHP will close this once the script has finished executing, so I tend to leave this out unless I expect to be doing more with the code once the file handle has served its purpose.

And there we have it, we have a working app that accepts a postcode and returns an image matching the weather found there.

Browser Caching

One thing that browsers and web clients (including email clients) are very good at is caching responses to avoid hitting the server repeatedly when it's not needed. In order to best leverage this, we need to output some headers that inform the browser how to best cache the image response.

Start by adding in this namespace to the ImageController:

use Carbon\Carbon;

This is a great library for handling dates and times, and means we won't be needing to write our own code for manipulating dates and formatting date strings. Next, add in a variable that determines how long the cache will be. The reason for this being a variable is that we will use it more than once for different caching headers:

private int $cachePeriodSeconds = 60 * 60 * 12;

While we could have put the value 43,200 here instead to denote 12 hours, writing it out like this makes it a lot clearer what period of time that the number represents.

Lastly, we can add in these two header lines (besides the existing content type and length lines):

header("Cache-Control: max-age: $this->cachePeriodSeconds"); header("Expires: " . Carbon::now()->addSeconds( $this->cachePeriodSeconds)->toRfc7231String() );

Handling Peak Traffic

One thing I've not mentioned in detail yet is how to handle peak traffic. What if this was part of an email campaign and several thousand recipients opened their email at the same time? That would mean a lot of processing and API requests in a short space of time. It would be a lot better if we could spread that load out across a more manageable period of time.

The most obvious way to do this would be to make calls against your app with various postcodes, in order to pre-cache the image responses. Because of what we're doing, existing Laravel pre-cache mechanisms aren't particularly suitable. We do already have caching of the API call that we already built in to our app, so we utilise that to pre-cache the responses. We can achieve this with a command that can be called as part of a recurring scheduled (cron) job.

Create the Command

First, we'll need a command to run that can help pre-cache the weather:

php artisan make:command PreCacheWeatherCommand

This create a new command template class in app/Console/Commands with the signature app:pre-cache-weather-command, which we can use later to call this command.

Next, let's add in our list of postcode areas, we only need the letters for this:

protected $postcodeAreas = ['AB', 'AL', 'B', 'BA', 'BB', 'BD', 'BH', 'BL', 'BN', 'BR', 'BS', 'BT', 'CA', 'CB', 'CF', 'CH', 'CM', 'CO', 'CR', 'CT', 'CV', 'CW', 'DA', 'DD', 'DE', 'DG', 'DH', 'DL', 'DN', 'DT', 'DY', 'E', 'EC', 'EH', 'EN', 'EX', 'FK', 'FY', 'G', 'GL', 'GU', 'GY', 'HA', 'HD', 'HG', 'HP', 'HR', 'HS', 'HU', 'HX', 'IG', 'IM', 'IP', 'IV', 'JE', 'KA', 'KT', 'KW', 'KY', 'L', 'LA', 'LD', 'LE', 'LL', 'LN', 'LS', 'LU', 'M', 'ME', 'MK', 'ML', 'N', 'NE', 'NG', 'NN', 'NP', 'NR', 'NW', 'OL', 'OX', 'PA', 'PE', 'PH', 'PL', 'PO', 'PR', 'QC', 'RG', 'RH', 'RM', 'S', 'SA', 'SE', 'SG', 'SK', 'SL', 'SM', 'SN', 'SO', 'SP', 'SR', 'SS', 'ST', 'SW', 'SY', 'TA', 'TD', 'TF', 'TN', 'TQ', 'TR', 'TS', 'TW', 'UB', 'W', 'WA', 'WC', 'WD', 'WF', 'WN', 'WR', 'WS', 'WV', 'YO', 'ZE' ];

The handle() method is going to be doing the work here, looping through our postcode areas, and calling our image endpoint for each one:

public function handle() { foreach ($this->postcodeAreas as $postcode) { $url = route('weather', ['postcodeArea' => $postcode]); $response = Http::get($url); if(!$response->successful()) { $this->error("Failed to get weather for postcode: $postcode"); } } }

The command should automatically be registered (as long as it remains in the app/Console/Commands directory where Laravel expects it), so you can run it like any other artisan command now:

php artisan app:pre-cache-weather-command

Scheduling the Command

While the command we have is useful, it's very manual, and you can't be expected to manually run this every so often. The solution is to schedule the command to be run at regular intervals. There are two ways to do this:

  1. Run this as a cron job, using crontab -e in your shell
  2. Use the Laravel Scheduler

While both methods achieve the same thing, the Laravel Scheduler does have the benefit that the control of the schedule is in the same code as the task that's being run. It moves the configuration and setup into the same codebase, and avoids needing a messy crontab. You also benefit from more readable syntax than something like this:

0 */4 * * * bash cd /www/var/html/weather_img && php artisan app:pre-cache-weather-command >> /dev/null 2>&1

Imagine needing to do this for all the scheduled jobs in a large application, and then needing to remember what the exact cron syntax is every time you need to change their frequency!

It's very easy to set up jobs in the scheduler by adding entries into the schedule() method of the app/Console/Kernel.php file:

protected function schedule(Schedule $schedule): void { $schedule->command('app:pre-cache-weather-command')->everyFourHours(); }

There are lots of built-in frequency options, or you can build up your own exact scheduling using the other methods built into the Schedule class.

The final step is to add one single entry to your crontab (this will be the only one you need to add), according to the Laravel Scheduler documentation:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

The Final Results

I've added a version here to showcase what it does using some drink images I created with AI.

While this is a very simple demo, it scales well enough to a small email campaign in the UK, but you would probably need some changes were it being used somewhere much larger. The addition of a CDN would greatly help there with the performance, and perhaps even scaling it out so that multiple instances of the image creation command could be run simultaneously, handling different batches of locations. You might want to use a different API, or even adapt the lookup so that it operates on latitude and longitude rather than postcodes, altering the precision until you get something that is accurate enough for any location across the world.

So, here is a demo version, but bear in mind that because this is limited to the UK, the weather is likely to be rain and clouds!

Select Location
AI generated image showing a drink suitable for the current weather at the selected location.

Comments

Leave a comment