Create a weather forecast Web API with Laravel (2)

I created a weather forecasting Web API with Laravel 8.83.11. The source code is available at the following github repository.

https://github.com/fukagai-takuya/weather-forecast

# The weather forecast data is obtained from the external site via Web API and stored in a database. This is a simple Web API program using Laravel.

# This blog page explains the source code and the commands that were used when the program was created. An overview of the program and how to check its operation is described on this blog page.

# In this blog post, I summarized the commands and code used to create the Routing, Controllers, and Events portions of the code. The rest is summarized on the next blog post.

# All dates and times are in UTC.

1. Installation
There are multiple ways to install a Laravel project. I used the following command this time. The following command will create a new Laravel project under the directory, weather-forecast.

$ composer create-project laravel/laravel weather-forecast

2. Routing
Specify the method to be executed when the Web API is called by an external HTTP client such as a web browser or Postman. Laravel provides routes/api.php or routes/web.php to describe the method to be called when each address is specified.

The created program is a simple one that returns weather forecast data in JSON format for a given date and time when the Web API is called with GET method, specifying a date and time parameter. Routing can be specified in routes/api.php for this purpose.

I added the following code at the end of routes/api.php

Route::get('/get-weather-forecast', [WeatherForecastInquiryController::class, 'getWeatherForecast']);

The method getWeatherForecast of class WeatherForecastInquiryController is called when the following address is specified. (In this example, Laravel is started on port 8000 at localhost.)

http://localhost:8000/api/get-weather-forecast

3. Controllers
Write the request handling code called from routes/api.php in the Controller class.

First, create a template for a Controller class named WeatherForecastInquiryController with the following command.

$ php artisan make:controller WeatherForecastInquiryController

Executing the above command will create a file app/Http/Controllers/WeatherForecastInquiryController.php with the following contents.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class WeatherForecastInquiryController extends Controller
{
    //
}

Edit this file and write the following code. The method getWeatherForecast(Request \$request) is the request handling method specified in routes/api.php.

<?php

namespace App\Http\Controllers;

use App\Events\WeatherForecastInquiryEvent;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

use DateTime;


class WeatherForecastInquiryController extends Controller
{
    public function getWeatherForecast(Request $request) {

        $dt_txt = $request->date;
        if (!preg_match('/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\z/', $dt_txt)) {
            $return_value = ['Result' => 'Failed', 'Error' => 'Format Error', 'Date' => $request->date];
            return response()->json($return_value);
        }

        $dt = 0;

        try {
            $dt_obj = new DateTime($dt_txt);
            $dt = $dt_obj->getTimestamp();
        } catch (\Exception $ex) {
            $dt = false;
        }

        if ($dt === false || $dt === 0) {
            $return_value = ['Result' => 'Failed', 'Error' => 'Incorrect Date', 'Date' => $request->date];
            return response()->json($return_value);
        }

        $dt_one_and_a_half_hours_ago = $dt - 5400;
        $dt_one_and_a_half_hours_later = $dt + 5400;

        $weather_data = DB::table('weather_data')
                        ->where('dt', '>=', $dt_one_and_a_half_hours_ago)
                        ->where('dt', '<', $dt_one_and_a_half_hours_later)->get();

        $weather_data_array = $weather_data->toArray();

        if (empty($weather_data_array)) {
            WeatherForecastInquiryEvent::dispatch();
            $weather_data = DB::table('weather_data')
                            ->where('dt', '>=', $dt_one_and_a_half_hours_ago)
                            ->where('dt', '<', $dt_one_and_a_half_hours_later)->get();
            $weather_data_array = $weather_data->toArray();
        }

        if (empty($weather_data_array)) {
            $return_value = ['Result' => 'Failed', 'Error' => 'No weather data was found for the specified date.', 'Date' => $request->date];
            return response()->json($return_value);
        }

        $weather_response_result = ['Result' => 'Success'];
        $weather_response = $weather_data_array[0];
        $weather_response = json_decode(json_encode($weather_response), true);
        $weather_response = array_merge($weather_response_result, $weather_response);
        return response()->json($weather_response);
    }
}
http://localhost:8000/api/get-weather-forecast?date=2022-05-13 10:25:49

When the Web API is called with a request parameter as shown above, the value of the request parameter “date” can be referenced with the following code. In the case of the above example address, the following code will assign “2022-05-13 10:25:49” to \$dt_txt.

$dt_txt = $request->date;

The JSON response returned by this Web API is set as an argument to response()->json($return_value) method as in the example below.

$return_value = ['Result' => 'Failed', 'Error' => 'Format Error', 'Date' => $request->date];
return response()->json($return_value);

The method getWeatherForecast(Request \$request) does the following process.

  1. Assigns the contents of the request parameter, “date” to \$dt_txt.
  2. Check that \$dt_txt meets the prescribed format.
  3. Convert \$dt_txt to a Unix timestamp representing the seconds elapsed from “January 1, 1970, 0:00:00 AM” and assign it to \$dt. It also checks the number representing the date and time is in the correct range.
  4. Calculate the numbers of seconds which are 1.5 hours before and after \$dt.
  5. Get weather forecast data from database table “weather_data”. The selected data is within 1.5 hours before and after \$dt.
  6. If there is no weather forecast data in the database table “weather_data” for an hour and a half before or after \$dt, WeatherForecastInquiryEvent::dispatch(); retrieves the latest weather forecast data from the external site. If no data for the relevant date and time is found, an error is returned.
  7. If weather forecast data for the relevant date and time is found, it is returned as a JSON response.

Because the code specifies the following namespace to be used,

namespace App\Http\Controllers;

When accessing Exception in the global namespace, the code is written as \Exception with a backslash, as shown below.

try {
    $dt_obj = new DateTime($dt_txt);
    $dt = $dt_obj->getTimestamp();
} catch (\Exception $ex) {
    $dt = false;
}

In addition, to access the class DateTime, also in the global namespace, the following code is written at the top of the file.

use DateTime;

If you write the following code, a debug log is output to ./storage/logs/laravel.logs.

Log::debug('An informational message.');

4. Events
Acquisition of weather forecast data from the external site was implemented with Laravel’s Event and Listener.

The Event retrieving weather forecast data is issued when the Controller asks for the weather data and the corresponding weather forecast data is not in the database. It is also issued when a Job that is started once every 6 hours retrieves the latest data.

Register the class WeatherForecastInquiryEvent as Event and the class WeatherForecastInquiryNotification to receive notifications of Event at app/Providers/EventServiceProvider.php. Register the listen property as shown in the code below.

In the example below, the only Listener for WeatherForecastInquiryEvent is WeatherForecastInquiryNotification, but multiple Listeners can be specified for a single Event. Multiple Events can also be registered.

This will cause the corresponding Listener to be triggered when the registered Event is issued.

namespace App\Providers;

use App\Events\WeatherForecastInquiryEvent;
use App\Listeners\WeatherForecastInquiryNotification;

...

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array<class-string, array<int, class-string>>
     */
    protected $listen = [
        WeatherForecastInquiryEvent::class => [
            WeatherForecastInquiryNotification::class,
        ],
    ];

    ...
}

The following commands generate class files for WeatherForecastInquiryEvent and WeatherForecastInquiryNotification.

$ php artisan make:event WeatherForecastInquiryEvent
$ php artisan make:listener WeatherForecastInquiryNotification --event=WeatherForecastInquiryEvent

The following two files are generated.

  • app/Events/WeatherForecastInquiryEvent.php
  • app/Listeners/WeatherForecastInquiryNotification.php

Of the two generated files, only WeatherForecastInquiryNotification.php has been modified.
The modified code for WeatherForecastInquiryNotification.php is shown below.

<?php

namespace App\Listeners;

use App\Events\WeatherForecastInquiryEvent;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class WeatherForecastInquiryNotification
{
    private const openweathermap_url = 'https://api.openweathermap.org/data/2.5/forecast';
    private const openweathermap_appid = '199b75177d487aaadd4e634813b3b7ce';
    private const city_data = [ ['40.730610', '-73.935242', 'new_york'],
                                ['51.509865', '-0.118092', 'london'],
                                ['48.864716', '2.349014', 'paris'],
                                ['52.520008', '13.404954', 'berlin'],
                                ['35.652832', '139.839478', 'tokyo'] ];

    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     *
     * @param  \App\Events\WeatherForecastInquiryEvent  $event
     * @return void
     */
    public function handle(WeatherForecastInquiryEvent $event)
    {
        $weather_dt_cities_list = $this->getWeatherFromOpenWeatherMap();

        $columns_to_be_updated = ['new_york_main', 'new_york_description', 'london_main', 'london_description',
                                  'paris_main', 'paris_description', 'berlin_main', 'berlin_description',
                                  'tokyo_main', 'tokyo_description'];

        DB::table('weather_data')->upsert($weather_dt_cities_list, ['dt'], $columns_to_be_updated);
    }


    private function getWeatherFromOpenWeatherMap()
    {
        $openweathermap_responses = Http::pool(fn (Pool $pool) => [
            $pool->get(self::openweathermap_url, ['lat' => self::city_data[0][0], 'lon' => self::city_data[0][1], 'appid' => self::openweathermap_appid]),
            $pool->get(self::openweathermap_url, ['lat' => self::city_data[1][0], 'lon' => self::city_data[1][1], 'appid' => self::openweathermap_appid]),
            $pool->get(self::openweathermap_url, ['lat' => self::city_data[2][0], 'lon' => self::city_data[2][1], 'appid' => self::openweathermap_appid]),
            $pool->get(self::openweathermap_url, ['lat' => self::city_data[3][0], 'lon' => self::city_data[3][1], 'appid' => self::openweathermap_appid]),
            $pool->get(self::openweathermap_url, ['lat' => self::city_data[4][0], 'lon' => self::city_data[4][1], 'appid' => self::openweathermap_appid]),
        ]);

        $dt_array = [];
        $weather_dt_list = [];
        $num_city_data = count($openweathermap_responses);
        for ($i = 0; $i < $num_city_data; $i++) {
            $openweathermap_json = $openweathermap_responses[$i]->json();
            $weather_dt_list[$i] = $this->getWeatherDtList($openweathermap_json, self::city_data[$i][2]);
            $dt_array = array_merge($dt_array, array_keys($weather_dt_list[$i]));
        }

        $dt_array = array_unique($dt_array);
        $weather_dt_cities_list = [];
        foreach ($dt_array as $dt) {
            $weather_dt = [];
            for ($i = 0; $i < $num_city_data; $i++) {
                $weather_contensts_map_with_dt = $weather_dt_list[$i][$dt];
                if (isset($weather_contensts_map_with_dt)) {
                    $weather_dt += $weather_contensts_map_with_dt;
                }
            }
            $weather_dt_cities_list[] = $weather_dt;
        }

        return $weather_dt_cities_list;
    }


    private function getWeatherDtList($openweathermap_json, $city)
    {
        $weather_list = $openweathermap_json['list'];
        $weather_dt_list = [];

        foreach ($weather_list as $weather_item) {
            $dt = $weather_item['dt'];
            $weather_dt_item['dt'] = $weather_item['dt'];
            $weather_dt_item['dt_txt'] = $weather_item['dt_txt'];
            $weather_contents = $weather_item['weather'];
            if (!empty($weather_contents) && !empty($weather_contents[0])) {
                if (isset($weather_contents[0]['main'])) {
                    $weather_dt_item[$city . '_main'] = $weather_contents[0]['main'];
                }
                if (isset($weather_contents[0]['description'])) {
                    $weather_dt_item[$city . '_description'] = $weather_contents[0]['description'];
                }
            }
            $weather_dt_list[$dt] = $weather_dt_item;
        }

        return $weather_dt_list;
    }
}

When a WeatherForecastInquiryEvent is issued, the handle method of WeatherForecastInquiryNotification is invoked.

From the handle method, call getWeatherFromOpenWeatherMap() to retrieve next 5 days/3-hourly weather forecast data from the external site for 5 cities: New York, London, Paris, Berlin, and Tokyo.

Next, use the following code to insert or update the weather forecast data on the database.

The upsert method provided in Laravel registers the corresponding data if it is not registered yet, or updates it with new data if it has already been registered; the first argument of the upsert method is the list of data to register or update, the second argument is unique columns. The third argument is a list of column names whose values are to be updated if a record exists that matches the column value of the second argument.

$columns_to_be_updated = ['new_york_main', 'new_york_description', 'london_main', 'london_description',
                          'paris_main', 'paris_description', 'berlin_main', 'berlin_description',
                          'tokyo_main', 'tokyo_description'];
DB::table('weather_data')->upsert($weather_dt_cities_list, ['dt'], $columns_to_be_updated);

The text below is an example of logging the first argument of the upsert method, \$weather_dt_cities_list .

array (
  0 => 
  array (
    'dt' => 1652616000,
    'dt_txt' => '2022-05-15 12:00:00',
    'new_york_main' => 'Clouds',
    'new_york_description' => 'overcast clouds',
    'london_main' => 'Rain',
    'london_description' => 'light rain',
    'paris_main' => 'Clear',
    'paris_description' => 'clear sky',
    'berlin_main' => 'Clear',
    'berlin_description' => 'clear sky',
    'tokyo_main' => 'Rain',
    'tokyo_description' => 'light rain',
  ),

  ...

  39 => 
  array (
    'dt' => 1653037200,
    'dt_txt' => '2022-05-20 09:00:00',
    'new_york_main' => 'Clouds',
    'new_york_description' => 'overcast clouds',
    'london_main' => 'Clouds',
    'london_description' => 'overcast clouds',
    'paris_main' => 'Rain',
    'paris_description' => 'light rain',
    'berlin_main' => 'Clouds',
    'berlin_description' => 'scattered clouds',
    'tokyo_main' => 'Clouds',
    'tokyo_description' => 'overcast clouds',
  ),
)  

The log above is a list of 40 associative arrays with indices from 0 to 39. Since the data is for 5 days, 8 times a day, every 3 hours, there are 40 date/time data. The list contains the values of the 40 records with the following columns. If a record with the same dt value has already been registered, the record data of the column name specified in the third argument \$columns_to_be_updated is updated. If a record with the same dt value has not been registered yet, it would be registered as a new record.

dt, dt_txt, new_york_main, new_york_description, 
london_main, london_description, paris_main, paris_description, 
berlin_main, berlin_description, tokyo_main, tokyo_description

Weather forecast data for the next 5 days for 5 cities are queried to an external site via Web API, specifying latitude and longitude. Since this process is time-consuming, the Http::pool method is used for parallel processing, as described here.

The following code retrieves the weather forecast data using the Http::pool method. As arguments to the \$pool->get() method, I passed the address of the external site, the latitude and longitude of each city, and the appid used in the Web API.

$openweathermap_responses = Http::pool(fn (Pool $pool) => [
    $pool->get(self::openweathermap_url, ['lat' => self::city_data[0][0], 'lon' => self::city_data[0][1], 'appid' => self::openweathermap_appid]),
    $pool->get(self::openweathermap_url, ['lat' => self::city_data[1][0], 'lon' => self::city_data[1][1], 'appid' => self::openweathermap_appid]),
    $pool->get(self::openweathermap_url, ['lat' => self::city_data[2][0], 'lon' => self::city_data[2][1], 'appid' => self::openweathermap_appid]),
    $pool->get(self::openweathermap_url, ['lat' => self::city_data[3][0], 'lon' => self::city_data[3][1], 'appid' => self::openweathermap_appid]),
    $pool->get(self::openweathermap_url, ['lat' => self::city_data[4][0], 'lon' => self::city_data[4][1], 'appid' => self::openweathermap_appid]),
]);

Weather forecast data (JSON response) for five cities received from the external site is summarized by adding city names to make the data suitable for the first argument of the upsert method. dt values are referenced and data for the same date and time are integrated.

Leave a Reply

Your email address will not be published. Required fields are marked *

CAPTCHA