DEV Community

Cover image for Workplaces for digital nomads: the API
Vladimir
Vladimir

Posted on

Workplaces for digital nomads: the API

Another pet project: cafés and co-working spaces in sunny Cyprus. Workplaces for digital nomads ヽ(。_°)ノ

I like to share my development process. The overall development approach is pragmatic and similar to the previous project.

The project's goals

There are numerous cafes, coffee houses, taverns, restaurants, and bars on the island, but not everyone is good to work for at least a couple of hours.
There are well-known Starbucks, Costa Coffee, Gloria Jeans Coffee, and so on, but there are also very cosy and totally underrated local places.
That is why it was decided:

  • Classify locations based on factors relevant to remote working, such as kitchen, outlets, noise, size, occupancy, view, and so on.
  • Filter locations by the parameters you've chosen.
  • Display a map with relevant locations.
  • Create a desktop and mobile web app.

Overall, everything was successful and the project code is open. Website: https://workplaces.cy/

To achieve the objectives, it was decided to build a REST API microservice on Laravel, with an admin panel on Twill and a frontend web application on Vue. Deploy on Fly.io as before.

REST API microservice

The platform chosen is the familiar and lightweight Laravel and PHP 8.1 with promoted- and readonly- properties and strict typing.

The composer.json and project configuration is lightened as much as possible: unused packages and classes are removed, platform-check is disabled, and classmap-authoritative is enabled.

As a result, the number of classes to be loaded decreased 4.5 times from 28247 to 6230, the vendor directory was "thinned" almost 1.5 times, and tests were slightly faster.

Architecture

The main Place model is a typical Laravel model that extended Twill model (A17\Twill\Models\Model).

Properties for filtering - native PHP enum's with a few common methods from EnumValues trait to obtain values for the admin panel. These are cast in the properties of the model.

In addition, each property has a coefficient and a weight to calculate an place's rating. For example, the availability of outlets is more important than the view.

enum Sockets: string implements PropertyEnum
{
    use EnumValues;

    case None = 'None';
    case Few = 'Few';
    case Many = 'Many';

    public const WEIGHT = 3;


    public static function default(): self
    {
        return self::Few;
    }


    /** @inheritDoc */
    public function coefficient(): int
    {
        return match ($this) {
            self::None => 1,
            self::Few => 3,
            self::Many => 5,
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

API queries are handled by single-action controllers, validated by Request including enum matching. For example, IndexRequest.

#[OA\Parameter(name: 'busyness', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'city', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'size', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'sockets', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'noise', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'type', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'view', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'cuisine', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string'))]
#[OA\Parameter(name: 'vRate', in: 'query', allowEmptyValue: false, schema: new OA\Schema(type: 'string', format: 'float', maximum: 0, minimum: 5))]
final class IndexRequest extends FormRequest
{
    /** @return array{busyness: string, city: string, size: string, sockets: string, noise: string, type: string, view: string} */
    public function rules(): array
    {
        return [
            'busyness' => ['sometimes', 'required', new Enum(Busyness::class)],
            'city' => ['sometimes', 'required', new Enum(City::class)],
            'size' => ['sometimes', 'required', new Enum(Size::class)],
            'sockets' => ['sometimes', 'required', new Enum(Sockets::class)],
            'noise' => ['sometimes', 'required', new Enum(Noise::class)],
            'type' => ['sometimes', 'required', new Enum(Type::class)],
            'view' => ['sometimes', 'required', new Enum(View::class)],
            'cuisine' => ['sometimes', 'required', new Enum(Cuisine::class)],
            'vRate' => ['sometimes', 'required', 'float', 'numeric', 'between:0,5'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Native PHP attributes allowed OpenAPI markup to be much more compact than in DocBlocks. The resulting openapi.yaml is created with swagger-php and used to test the API.

Apart from the validators, requests are passed through EloquentFilter-based filters - a very expressive solution instead of a bunch of if's and when's.

Some places have photos that are transparently uploaded to AWS S3 from the admin and processed by the Imgix service. There is nothing on the API side to handle the pictures.

To get detailed geodata for the place from Google Maps, GooglePlacesService and alexpechkarev/google-maps package are used. In API service all the places are added only with the name, city and properties for rating. The rest data - coordinates, business ID, address and link are obtained from Google Places API.

To calculate the rating of an place VRateService is used.

Both services are wrapped in their respective actions and are available through console commands and events after recording the place.

The finished data is wrapped in PlaceResource and PlaceCollection. Excessive fields are also removed there. The middleware JsonResponse.php is used to force response in JSON format

final class JsonResponse
{
    /** @param Closure(Request): (BaseJsonResponse) $next */
    public function handle(Request $request, Closure $next): BaseJsonResponse
    {
        $request->headers->set('Accept', 'application/json');

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Administrative control panel

I've worked with Twill before, so I decided to use it for my project: an open, free system with rich features and good support. Why not? :-)

Installed via composer requires area17/twill, adds some migrations and communicates transparently with existing models. In some cases, you need to add service fields like `published and activity start/stop dates to them. However, the documentation describes everything in detail.

Now I recommend to try version 3-beta: it has much more options to programmatically manage the data on pages instead of separate widgets in the blade templates.

Administrative control panel

Example of a controller, repository and template.

Database

Simple and fast SQLite ¯_(ツ)_/¯
Hosted on a persistent volume. No configuration required.

Tests

For the tests, Pest is used with Laravel support, parallel execution of tests, and with disabled throttling ($this->withoutMiddleware(ThrottleRequests::class).

The endpoints are used to check datasets responses and their consistency with the OpenAPI specification.

For manual check there is Rector with some exceptions.

Found one downside: laravel/dusk and php-webdriver/webdriver are nailed to Twill and require mandatory installation, although not used in my tests :-(

Deployment

The server is hosted on the Fly.io platform with managed micro VM Firecracker. It never sleeps, has a good free tier, and allows you to host both static and any application server, unlike the popular Heroku. There are also different deployment and rollback strategies, health checks, and hosting geographies to choose from.

The runtime can be set up automatically with the flyctl launch command from the application directory or you can write your own config and Dockerfile.

I used my Dockerfile and run API microservice in the easiest way via php artisan serve.

Static distribution (admin assets and robots.txt & Co) can be delegated to the Fly platform by configuring fly.toml

[[statics]]
guest_path = "/var/www/html/public/assets"
url_prefix = "/assets"

CI/CD

It's simple here, GitHub Action with one workflow and the same flyctl.

Monitoring

Sentry is used for errors tracking and Honeybadger is used for uptime and availability checks.

At this point, the microservice API is live, hosted in production, and accessible to all users. MVP is complete :-)

API repository, website https://workplaces.cy/

I'll tell about creating the frontend with Vue 3 Composition API in the second part.

Top comments (0)