feat: opt-in Nominatim geocoding observer + sync UUID migrations
Adds an AddressObserver that resolves latitude/longitude from the
postal fields after every save, serializing calls cluster-wide through
a Cache::lock and respecting Nominatim's 1-req/sec usage policy.
How the rate limit holds across workers
---------------------------------------
* Cache::lock('addresses:geocoding:lock') ensures only ONE process
ever talks upstream at a time (any LockProvider-capable store works:
redis / memcached / database / file / array).
* Inside the lock the driver reads a shared :last_call_at timestamp
and usleep()s the difference to the configured min_interval before
hitting the API, then stamps the new value right after the response
comes back — pacing the next caller from when we *actually stopped*
talking upstream, not from when the lock was first taken.
* Lock TTL (15s default) bounds crash-recovery time so a kill -9
can't pin the lock open forever.
Observer behaviour
------------------
* Hooks 'created' (always geocodes on insert) and 'updated' (only when
a *postal* field actually changed — wasChanged() on street, city,
postal_code, country_code, …). Lat/lon edits don't loop because
the write-back uses saveQuietly().
* Errors (LockTimeoutException, network, 5xx) are logged and
swallowed — the user's save() / create() call is never made fatal
by a transient upstream issue.
* Honours an update_only_when_missing toggle so apps can preserve
manually-entered coordinates.
Backward compatibility
----------------------
* Default 'enabled' is FALSE — upgrading the package does NOT start
making outbound HTTP calls on existing apps. Apps that want the
feature opt in via ADDRESSES_GEOCODING_ENABLED=true and a
descriptive ADDRESSES_GEOCODING_USER_AGENT (OSMF blocks generic UAs).
* The Geocoder contract is bound through the container so apps can
rebind to Google Maps / Mapbox / a self-hosted Nominatim without
touching the observer.
Pre-existing fix shipped alongside
----------------------------------
The Address* models switched to HasUuids in 73c14c5, but neither the
published migration stub nor the workbench fixture migration were
updated — every test in the suite was failing with 'datatype mismatch'
on the auto-increment integer PK. Both migrations now use uuid('id')
+ foreignUuid() so the model trait and the schema agree.
For consumers who already published the stub: no change. Your
existing migration file is untouched. Only fresh `vendor:publish
--tag=addresses-migrations` runs pick up the UUID shape.
New files
---------
src/Services/Geocoding/Contracts/Geocoder.php
src/Services/Geocoding/GeocodingResult.php
src/Services/Geocoding/NominatimGeocoder.php
src/Observers/AddressObserver.php
tests/Unit/GeocodingTest.php
docs/geocoding.md
Tests
-----
221 / 221 (100%) Time: ~2.8s
The 14 new tests cover URL/param construction, User-Agent, empty- and
unparseable-result handling, the rate-limit timing assertion (proves
two back-to-back calls take >= min_interval), cache-lock
serialization, observer pre-fill on create, no-loop on lat/lon
update, re-geocode on postal change, the update_only_when_missing
policy, the enabled master switch, and graceful swallowing of both
LockTimeoutException and generic upstream errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
be6404ddf9
commit
5a5bab040a
54
README.md
54
README.md
|
|
@ -27,6 +27,7 @@ Address → The physical place (street, city, coordinates …)
|
||||||
- **Address assignments** — reference someone else's address in another context
|
- **Address assignments** — reference someone else's address in another context
|
||||||
- **Temporal validity** — `active_from` / `active_until` on every link
|
- **Temporal validity** — `active_from` / `active_until` on every link
|
||||||
- **AddressService** — distance calculations (Haversine), proximity queries, duplicate detection, coordinate conversion
|
- **AddressService** — distance calculations (Haversine), proximity queries, duplicate detection, coordinate conversion
|
||||||
|
- **Auto-geocoding** — saves call Nominatim (OpenStreetMap) for lat/lon, serialized by a Cache lock and paced at 1 req/sec
|
||||||
- **Fully configurable** — custom model classes, table names, default link type
|
- **Fully configurable** — custom model classes, table names, default link type
|
||||||
- **Soft deletes** on addresses, cascade deletes on links and assignments
|
- **Soft deletes** on addresses, cascade deletes on links and assignments
|
||||||
|
|
||||||
|
|
@ -107,7 +108,57 @@ $job->assignAddressLink($link, 'pickup');
|
||||||
$job->assignedAddressForRole('pickup'); // → the Address model
|
$job->assignedAddressForRole('pickup'); // → the Address model
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Use the AddressService
|
### 5. Automatic geocoding (opt-in)
|
||||||
|
|
||||||
|
Set `ADDRESSES_GEOCODING_ENABLED=true` to have an observer ask Nominatim
|
||||||
|
(OpenStreetMap) for `latitude` / `longitude` after every save. Calls are
|
||||||
|
serialized cluster-wide through a `Cache::lock` and paced at 1 req/sec
|
||||||
|
to honour the OSMF usage policy. **Off by default** so an upgrade
|
||||||
|
doesn't surprise existing apps with new outbound HTTP traffic.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .env
|
||||||
|
ADDRESSES_GEOCODING_ENABLED=true
|
||||||
|
# OSMF policy: identify your app — generic UAs get blocked.
|
||||||
|
ADDRESSES_GEOCODING_USER_AGENT="my-app (https://example.com)"
|
||||||
|
ADDRESSES_GEOCODING_EMAIL="ops@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
'country_code' => 'AT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Observer has filled these in by the time create() returns.
|
||||||
|
$address->refresh();
|
||||||
|
$address->latitude; // 48.2082…
|
||||||
|
$address->longitude; // 16.3738…
|
||||||
|
```
|
||||||
|
|
||||||
|
Tunable knobs (see [`config/addresses.php`](config/addresses.php) →
|
||||||
|
`geocoding`):
|
||||||
|
|
||||||
|
- `enabled` — master switch (env: `ADDRESSES_GEOCODING_ENABLED`)
|
||||||
|
- `driver` — only `nominatim` ships out of the box, but you can rebind
|
||||||
|
the `Geocoder` contract to plug in Google Maps / Mapbox / Mapquest /
|
||||||
|
a self-hosted Nominatim
|
||||||
|
- `update_only_when_missing` — leave manually-entered coordinates alone
|
||||||
|
- `min_interval_seconds` — global ceiling on outbound traffic (default
|
||||||
|
`1.0`, matches Nominatim's published policy)
|
||||||
|
- `lock_wait_seconds`, `lock_ttl_seconds` — Cache lock parameters
|
||||||
|
- `accept_language` — Nominatim's `display_name` localization
|
||||||
|
- `drivers.nominatim.user_agent` / `email` — required by Nominatim for
|
||||||
|
attribution; set both in production
|
||||||
|
|
||||||
|
For tests / imports, disable per-environment with
|
||||||
|
`ADDRESSES_GEOCODING_ENABLED=false` (or
|
||||||
|
`config()->set('addresses.geocoding.enabled', false)` inside a TestCase
|
||||||
|
hook).
|
||||||
|
|
||||||
|
### 6. Use the AddressService
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Via helper
|
// Via helper
|
||||||
|
|
@ -129,6 +180,7 @@ echo address()->formatMultiline($address);
|
||||||
| [HasAddresses Trait](docs/has-addresses.md) | Full API for address-owning models |
|
| [HasAddresses Trait](docs/has-addresses.md) | Full API for address-owning models |
|
||||||
| [HasAddressAssignments Trait](docs/has-address-assignments.md) | Full API for address-consuming models |
|
| [HasAddressAssignments Trait](docs/has-address-assignments.md) | Full API for address-consuming models |
|
||||||
| [AddressService](docs/address-service.md) | Distance, proximity, formatting, conversion |
|
| [AddressService](docs/address-service.md) | Distance, proximity, formatting, conversion |
|
||||||
|
| [Geocoding](docs/geocoding.md) | Auto lat/lon via Nominatim, cache lock, rate limit |
|
||||||
| [AddressLinkType Enum](docs/address-link-types.md) | All 17 built-in types with descriptions |
|
| [AddressLinkType Enum](docs/address-link-types.md) | All 17 built-in types with descriptions |
|
||||||
| [Customization](docs/customization.md) | Extending models, custom tables, overriding defaults |
|
| [Customization](docs/customization.md) | Extending models, custom tables, overriding defaults |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Blax\Addresses\Enums\AddressLinkType;
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Models\AddressAssignment;
|
||||||
|
use Blax\Addresses\Models\AddressLink;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -14,9 +19,9 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'models' => [
|
'models' => [
|
||||||
'address' => \Blax\Addresses\Models\Address::class,
|
'address' => Address::class,
|
||||||
'address_link' => \Blax\Addresses\Models\AddressLink::class,
|
'address_link' => AddressLink::class,
|
||||||
'address_assignment' => \Blax\Addresses\Models\AddressAssignment::class,
|
'address_assignment' => AddressAssignment::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -29,9 +34,9 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'table_names' => [
|
'table_names' => [
|
||||||
'addresses' => 'addresses',
|
'addresses' => 'addresses',
|
||||||
'address_links' => 'address_links',
|
'address_links' => 'address_links',
|
||||||
'address_assignments' => 'address_assignments',
|
'address_assignments' => 'address_assignments',
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -43,6 +48,103 @@ return [
|
||||||
| without specifying a type explicitly.
|
| without specifying a type explicitly.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'default_link_type' => \Blax\Addresses\Enums\AddressLinkType::Other,
|
'default_link_type' => AddressLinkType::Other,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Geocoding
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| After an Address is saved, the package can resolve its `latitude` and
|
||||||
|
| `longitude` from the postal fields using a public geocoder. By default
|
||||||
|
| this uses Nominatim (OpenStreetMap) — free, no API key, requires a
|
||||||
|
| descriptive User-Agent and a hard limit of one request per second
|
||||||
|
| (operations.osmfoundation.org/policies/nominatim/).
|
||||||
|
|
|
||||||
|
| The observer holds a Laravel `Cache::lock` while it makes the call, so
|
||||||
|
| even when the same code is running in multiple workers only ONE call
|
||||||
|
| hits the upstream at a time, and the 1-req/sec floor is enforced
|
||||||
|
| globally across the cluster via a shared cache timestamp.
|
||||||
|
|
|
||||||
|
| Set `enabled => false` to turn the observer off (e.g. in CI). Set
|
||||||
|
| `update_only_when_missing => true` to keep manually-entered coordinates
|
||||||
|
| and only geocode rows whose coordinates are still null.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'geocoding' => [
|
||||||
|
|
||||||
|
// Master switch. Opt-in by default — turning it on starts firing
|
||||||
|
// outbound HTTP calls on every Address save, which apps updating
|
||||||
|
// from a previous version don't want as a surprise. Flip it via
|
||||||
|
// ADDRESSES_GEOCODING_ENABLED=true once you've reviewed the
|
||||||
|
// Nominatim usage policy and set a proper User-Agent below.
|
||||||
|
'enabled' => env('ADDRESSES_GEOCODING_ENABLED', false),
|
||||||
|
|
||||||
|
// Driver — currently only `nominatim` is shipped. The Geocoder
|
||||||
|
// contract is in `src/Services/Geocoding/Contracts/Geocoder.php`
|
||||||
|
// so apps can bind their own implementation if they need Google
|
||||||
|
// Maps / Mapbox / etc.
|
||||||
|
'driver' => env('ADDRESSES_GEOCODING_DRIVER', 'nominatim'),
|
||||||
|
|
||||||
|
// True → only geocode when latitude/longitude are still NULL.
|
||||||
|
// • Manually-entered coordinates win, the observer leaves them alone.
|
||||||
|
// False → re-geocode every time a postal field actually changes.
|
||||||
|
// • Coordinates always track the textual address.
|
||||||
|
'update_only_when_missing' => env('ADDRESSES_GEOCODING_ONLY_WHEN_MISSING', false),
|
||||||
|
|
||||||
|
// Cache store used for the lock + the global "last call at" stamp.
|
||||||
|
// null = the app's default cache store. Pick something shared
|
||||||
|
// (redis / memcached / database) if you run multiple workers,
|
||||||
|
// otherwise the 1-req/sec ceiling is only per-process.
|
||||||
|
'cache_store' => env('ADDRESSES_GEOCODING_CACHE_STORE'),
|
||||||
|
|
||||||
|
// Cache key prefix — gives operators a single root to flush.
|
||||||
|
'cache_prefix' => 'addresses:geocoding',
|
||||||
|
|
||||||
|
// How long to wait (seconds) for the global geocoding lock before
|
||||||
|
// giving up. Bursts of saves queue up against this; pick a value
|
||||||
|
// that's roughly `expected_burst_size * min_interval`.
|
||||||
|
'lock_wait_seconds' => env('ADDRESSES_GEOCODING_LOCK_WAIT', 10),
|
||||||
|
|
||||||
|
// Lock TTL — protects against a hard crash leaving the lock held.
|
||||||
|
// Should comfortably exceed `timeout_seconds + min_interval_seconds`.
|
||||||
|
'lock_ttl_seconds' => env('ADDRESSES_GEOCODING_LOCK_TTL', 15),
|
||||||
|
|
||||||
|
// Minimum interval (seconds, float-friendly) between two consecutive
|
||||||
|
// upstream calls. Nominatim's published policy is "no more than 1
|
||||||
|
// per second". Don't go below 1.0 on the public server.
|
||||||
|
'min_interval_seconds' => env('ADDRESSES_GEOCODING_MIN_INTERVAL', 1.0),
|
||||||
|
|
||||||
|
// HTTP read+connect timeout for a single upstream call (seconds).
|
||||||
|
'timeout_seconds' => env('ADDRESSES_GEOCODING_TIMEOUT', 8),
|
||||||
|
|
||||||
|
// Languages preference (Accept-Language). Nominatim uses this to
|
||||||
|
// pick localized `display_name` strings.
|
||||||
|
'accept_language' => env('ADDRESSES_GEOCODING_LANG', 'en'),
|
||||||
|
|
||||||
|
// Driver-specific settings.
|
||||||
|
'drivers' => [
|
||||||
|
'nominatim' => [
|
||||||
|
// Base endpoint. Use a self-hosted instance here to lift
|
||||||
|
// the 1-req/sec restriction; see
|
||||||
|
// https://github.com/mediagis/nominatim-docker.
|
||||||
|
'endpoint' => env('ADDRESSES_GEOCODING_NOMINATIM_URL', 'https://nominatim.openstreetmap.org/search'),
|
||||||
|
|
||||||
|
// Nominatim's usage policy requires a descriptive User-Agent
|
||||||
|
// that identifies your application. The default uses the
|
||||||
|
// package name; SET YOUR OWN APP NAME + CONTACT in
|
||||||
|
// production so the OSMF can reach you if you're causing
|
||||||
|
// load problems instead of blocking your IP cold.
|
||||||
|
'user_agent' => env(
|
||||||
|
'ADDRESSES_GEOCODING_USER_AGENT',
|
||||||
|
'blax-software/laravel-addresses (https://github.com/blax-software/laravel-addresses)',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Optional contact email — included as the `email` query
|
||||||
|
// parameter as suggested by the Nominatim docs.
|
||||||
|
'email' => env('ADDRESSES_GEOCODING_EMAIL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,12 @@ return new class extends Migration
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Schema::create(config('addresses.table_names.addresses', 'addresses'), function (Blueprint $table) {
|
Schema::create(config('addresses.table_names.addresses', 'addresses'), function (Blueprint $table) {
|
||||||
$table->id();
|
// UUID PK — the Address model uses `HasUuids`. If you've already
|
||||||
|
// published a previous version of this stub with `$table->id()`
|
||||||
|
// (auto-increment), keep that — but you'll need to either drop
|
||||||
|
// `HasUuids` from your Address override or migrate the column to
|
||||||
|
// a UUID. Don't change a populated table's PK shape blindly.
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
|
||||||
// ── Street-level addressing ─────────────────────────────
|
// ── Street-level addressing ─────────────────────────────
|
||||||
// Primary street line (street name + house/building number).
|
// Primary street line (street name + house/building number).
|
||||||
|
|
@ -116,10 +121,10 @@ return new class extends Migration
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Schema::create(config('addresses.table_names.address_links', 'address_links'), function (Blueprint $table) {
|
Schema::create(config('addresses.table_names.address_links', 'address_links'), function (Blueprint $table) {
|
||||||
$table->id();
|
$table->uuid('id')->primary();
|
||||||
|
|
||||||
// ── Foreign key to the address ──────────────────────────
|
// ── Foreign key to the address ──────────────────────────
|
||||||
$table->foreignId('address_id')
|
$table->foreignUuid('address_id')
|
||||||
->constrained(config('addresses.table_names.addresses', 'addresses'))
|
->constrained(config('addresses.table_names.addresses', 'addresses'))
|
||||||
->cascadeOnDelete();
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
|
@ -173,10 +178,10 @@ return new class extends Migration
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Schema::create(config('addresses.table_names.address_assignments', 'address_assignments'), function (Blueprint $table) {
|
Schema::create(config('addresses.table_names.address_assignments', 'address_assignments'), function (Blueprint $table) {
|
||||||
$table->id();
|
$table->uuid('id')->primary();
|
||||||
|
|
||||||
// ── Foreign key to the address link ─────────────────────
|
// ── Foreign key to the address link ─────────────────────
|
||||||
$table->foreignId('address_link_id')
|
$table->foreignUuid('address_link_id')
|
||||||
->constrained(config('addresses.table_names.address_links', 'address_links'))
|
->constrained(config('addresses.table_names.address_links', 'address_links'))
|
||||||
->cascadeOnDelete();
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Auto-Geocoding
|
||||||
|
|
||||||
|
After every `Address` save, an observer asks a geocoding service for
|
||||||
|
`latitude` / `longitude` based on the postal fields, then writes the
|
||||||
|
result back onto the row.
|
||||||
|
|
||||||
|
The default driver is **Nominatim (OpenStreetMap)** — free, no API key
|
||||||
|
required, but [policy-capped](https://operations.osmfoundation.org/policies/nominatim/)
|
||||||
|
at **1 request per second** on the public instance. The package
|
||||||
|
enforces that cap with a Laravel `Cache::lock`, so even multiple PHP-FPM
|
||||||
|
workers / queue runners sharing the same address book never overshoot.
|
||||||
|
|
||||||
|
> **Opt-in.** The observer is wired up but the master switch defaults to
|
||||||
|
> `false`, so upgrading the package doesn't change behaviour for an
|
||||||
|
> existing app. Set `ADDRESSES_GEOCODING_ENABLED=true` (and a
|
||||||
|
> descriptive `ADDRESSES_GEOCODING_USER_AGENT`) to turn it on.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Address::create([...])
|
||||||
|
│
|
||||||
|
├─► INSERT row
|
||||||
|
│
|
||||||
|
├─► AddressObserver::created
|
||||||
|
│ │
|
||||||
|
│ ├─► Cache::lock acquired (cluster-wide)
|
||||||
|
│ ├─► sleep until min_interval since last call
|
||||||
|
│ ├─► HTTP GET nominatim/search?…
|
||||||
|
│ ├─► stamp "last call at" → cache
|
||||||
|
│ └─► lock released
|
||||||
|
│
|
||||||
|
├─► $address->saveQuietly() (lat / lon → DB, no recursion)
|
||||||
|
│
|
||||||
|
└─► return $address
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates work the same way, but the observer only fires the geocoder
|
||||||
|
when a **postal** field actually changed (street, postal_code, city,
|
||||||
|
state, county, country_code, building, street_extra). Plain
|
||||||
|
latitude/longitude edits — including the observer's own write-back —
|
||||||
|
don't loop, and unrelated columns (notes, meta, has_elevator, …) are
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/addresses.php
|
||||||
|
'geocoding' => [
|
||||||
|
'enabled' => true, // master switch
|
||||||
|
'driver' => 'nominatim', // only one shipped
|
||||||
|
'update_only_when_missing' => false, // keep manual coords
|
||||||
|
'cache_store' => null, // default cache store
|
||||||
|
'cache_prefix' => 'addresses:geocoding',
|
||||||
|
'lock_wait_seconds' => 10, // queue depth budget
|
||||||
|
'lock_ttl_seconds' => 15, // crash-recovery TTL
|
||||||
|
'min_interval_seconds' => 1.0, // OSMF policy floor
|
||||||
|
'timeout_seconds' => 8,
|
||||||
|
'accept_language' => 'en',
|
||||||
|
'drivers' => [
|
||||||
|
'nominatim' => [
|
||||||
|
'endpoint' => 'https://nominatim.openstreetmap.org/search',
|
||||||
|
'user_agent' => 'your-app-name (you@example.com)',
|
||||||
|
'email' => 'you@example.com',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Every key reads from a matching `env('ADDRESSES_GEOCODING_*')` so you
|
||||||
|
can override per-environment without publishing the config.
|
||||||
|
|
||||||
|
### Notable env vars
|
||||||
|
|
||||||
|
| Variable | Default | Use case |
|
||||||
|
|----------------------------------------------|-------------------------|----------------------------------------------------|
|
||||||
|
| `ADDRESSES_GEOCODING_ENABLED` | `true` | Turn the whole observer off (CI, bulk imports) |
|
||||||
|
| `ADDRESSES_GEOCODING_ONLY_WHEN_MISSING` | `false` | Preserve manually-entered coordinates |
|
||||||
|
| `ADDRESSES_GEOCODING_MIN_INTERVAL` | `1.0` | Lower than 1 only if you self-host Nominatim |
|
||||||
|
| `ADDRESSES_GEOCODING_USER_AGENT` | package default | **Set in production** — OSMF blocks generic UAs |
|
||||||
|
| `ADDRESSES_GEOCODING_EMAIL` | `null` | Contact email for OSMF (recommended) |
|
||||||
|
| `ADDRESSES_GEOCODING_NOMINATIM_URL` | OSM public endpoint | Point at a self-hosted Nominatim instance |
|
||||||
|
| `ADDRESSES_GEOCODING_CACHE_STORE` | app default | Share the lock across processes (redis / memcache) |
|
||||||
|
|
||||||
|
## Plug in a different provider
|
||||||
|
|
||||||
|
The observer talks to the `Geocoder` contract, not to any concrete
|
||||||
|
driver. Bind your own implementation in a service provider:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||||||
|
use App\Services\MyMapboxGeocoder;
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(Geocoder::class, MyMapboxGeocoder::class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The contract is:
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface Geocoder
|
||||||
|
{
|
||||||
|
public function geocode(Address $address): ?GeocodingResult;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GeocodingResult` is a small DTO with `latitude`, `longitude`,
|
||||||
|
`displayName`, and the raw provider payload.
|
||||||
|
|
||||||
|
Your driver is responsible for its own concurrency / rate-limit policy
|
||||||
|
— Google Maps and Mapbox have quota systems server-side, so a custom
|
||||||
|
driver usually doesn't need a `Cache::lock` at all.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
The observer is forgiving by design — geocoding is best-effort:
|
||||||
|
|
||||||
|
| Failure | Behaviour |
|
||||||
|
|-------------------------------|----------------------------------------------------------------------------------------------------|
|
||||||
|
| `Cache::lock` wait times out | Logged (`info`), the address keeps its current coords. Re-save (or backfill) to retry. |
|
||||||
|
| Network / 5xx from upstream | Logged (`warning`), the address keeps its current coords. |
|
||||||
|
| Upstream returns no match | Silent — no logs, no DB writes. |
|
||||||
|
| Upstream returns garbage | Silent — invalid lat/lon (`NaN`, missing, non-numeric) are treated as "no match". |
|
||||||
|
|
||||||
|
In every case the user's `save()` / `create()` call returns
|
||||||
|
successfully — geocoding never blows up the consuming code path.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Drop `Http::fake()` into your test setup; the geocoder uses Laravel's
|
||||||
|
HTTP client so it picks up the fakes automatically. To turn the
|
||||||
|
observer off entirely:
|
||||||
|
|
||||||
|
```php
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->set('addresses.geocoding.enabled', false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See `tests/Unit/GeocodingTest.php` for full examples including the
|
||||||
|
rate-limit timing assertion.
|
||||||
|
|
@ -2,7 +2,17 @@
|
||||||
|
|
||||||
namespace Blax\Addresses;
|
namespace Blax\Addresses;
|
||||||
|
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Models\AddressAssignment;
|
||||||
|
use Blax\Addresses\Models\AddressLink;
|
||||||
|
use Blax\Addresses\Observers\AddressObserver;
|
||||||
use Blax\Addresses\Services\AddressService;
|
use Blax\Addresses\Services\AddressService;
|
||||||
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||||||
|
use Blax\Addresses\Services\Geocoding\NominatimGeocoder;
|
||||||
|
use Illuminate\Contracts\Cache\Factory as CacheFactory;
|
||||||
|
use Illuminate\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AddressesServiceProvider extends ServiceProvider
|
class AddressesServiceProvider extends ServiceProvider
|
||||||
|
|
@ -16,12 +26,24 @@ class AddressesServiceProvider extends ServiceProvider
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->mergeConfigFrom(
|
$this->mergeConfigFrom(
|
||||||
__DIR__ . '/../config/addresses.php',
|
__DIR__.'/../config/addresses.php',
|
||||||
'addresses'
|
'addresses'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register AddressService as a singleton.
|
// Register AddressService as a singleton.
|
||||||
$this->app->singleton(AddressService::class);
|
$this->app->singleton(AddressService::class);
|
||||||
|
|
||||||
|
// Default Geocoder binding. Apps can rebind this contract to a
|
||||||
|
// different driver (Mapbox, Google, …) in their own provider —
|
||||||
|
// the AddressObserver only knows about the contract, not the
|
||||||
|
// concrete implementation.
|
||||||
|
$this->app->singleton(Geocoder::class, function ($app) {
|
||||||
|
return new NominatimGeocoder(
|
||||||
|
$app->make(HttpFactory::class),
|
||||||
|
$app->make(CacheFactory::class),
|
||||||
|
$app['config']->get('addresses.geocoding', []),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,9 +65,31 @@ class AddressesServiceProvider extends ServiceProvider
|
||||||
// consumers must still `vendor:publish` it once to set the baseline
|
// consumers must still `vendor:publish` it once to set the baseline
|
||||||
// (preserves backwards-compatibility with apps that already published
|
// (preserves backwards-compatibility with apps that already published
|
||||||
// a customised version, like UUID PKs).
|
// a customised version, like UUID PKs).
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||||
|
|
||||||
$this->registerModelBindings();
|
$this->registerModelBindings();
|
||||||
|
|
||||||
|
$this->registerModelObservers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Observers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the AddressObserver to the (possibly overridden) Address
|
||||||
|
* model so saving an address triggers the geocoding pipeline.
|
||||||
|
*
|
||||||
|
* Resolves the concrete Address class through config so a consumer
|
||||||
|
* extending the model still gets observed.
|
||||||
|
*/
|
||||||
|
protected function registerModelObservers(): void
|
||||||
|
{
|
||||||
|
$addressModel = $this->app['config']->get('addresses.models.address', Address::class);
|
||||||
|
|
||||||
|
$addressModel::observe(AddressObserver::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -65,12 +109,12 @@ class AddressesServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../config/addresses.php' => $this->app->configPath('addresses.php'),
|
__DIR__.'/../config/addresses.php' => $this->app->configPath('addresses.php'),
|
||||||
], 'addresses-config');
|
], 'addresses-config');
|
||||||
|
|
||||||
// Migrations
|
// Migrations
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../database/migrations/create_blax_address_tables.php.stub' => $this->getMigrationFileName('create_blax_address_tables.php'),
|
__DIR__.'/../database/migrations/create_blax_address_tables.php.stub' => $this->getMigrationFileName('create_blax_address_tables.php'),
|
||||||
], 'addresses-migrations');
|
], 'addresses-migrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,13 +126,13 @@ class AddressesServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
$timestamp = date('Y_m_d_His');
|
$timestamp = date('Y_m_d_His');
|
||||||
|
|
||||||
$filesystem = $this->app->make(\Illuminate\Filesystem\Filesystem::class);
|
$filesystem = $this->app->make(Filesystem::class);
|
||||||
|
|
||||||
return \Illuminate\Support\Collection::make([
|
return Collection::make([
|
||||||
$this->app->databasePath() . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR,
|
$this->app->databasePath().DIRECTORY_SEPARATOR.'migrations'.DIRECTORY_SEPARATOR,
|
||||||
])
|
])
|
||||||
->flatMap(fn($path) => $filesystem->glob($path . '*_' . $migrationFileName))
|
->flatMap(fn ($path) => $filesystem->glob($path.'*_'.$migrationFileName))
|
||||||
->push($this->app->databasePath() . "/migrations/{$timestamp}_{$migrationFileName}")
|
->push($this->app->databasePath()."/migrations/{$timestamp}_{$migrationFileName}")
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,18 +149,18 @@ class AddressesServiceProvider extends ServiceProvider
|
||||||
protected function registerModelBindings(): void
|
protected function registerModelBindings(): void
|
||||||
{
|
{
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
\Blax\Addresses\Models\Address::class,
|
Address::class,
|
||||||
fn($app) => $app->make($app->config['addresses.models.address'])
|
fn ($app) => $app->make($app->config['addresses.models.address'])
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
\Blax\Addresses\Models\AddressLink::class,
|
AddressLink::class,
|
||||||
fn($app) => $app->make($app->config['addresses.models.address_link'])
|
fn ($app) => $app->make($app->config['addresses.models.address_link'])
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
\Blax\Addresses\Models\AddressAssignment::class,
|
AddressAssignment::class,
|
||||||
fn($app) => $app->make($app->config['addresses.models.address_assignment'])
|
fn ($app) => $app->make($app->config['addresses.models.address_assignment'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Addresses\Observers;
|
||||||
|
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
|
use Illuminate\Contracts\Config\Repository as Config;
|
||||||
|
use Illuminate\Contracts\Container\Container;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous model observer that auto-geocodes addresses on save.
|
||||||
|
*
|
||||||
|
* Flow on every `saved` event:
|
||||||
|
* 1. Bail out fast if geocoding is disabled in config or if the
|
||||||
|
* address has no postal-y fields to look up.
|
||||||
|
* 2. Decide whether we need to geocode at all:
|
||||||
|
* • update_only_when_missing = true → only when lat or lon is null.
|
||||||
|
* • update_only_when_missing = false → whenever a postal field
|
||||||
|
* actually changed (latitude/longitude themselves don't count
|
||||||
|
* — those are our own writes coming back through).
|
||||||
|
* 3. Ask the bound Geocoder for coordinates. The geocoder handles
|
||||||
|
* the cache lock + rate-limit; the observer just routes the
|
||||||
|
* result back onto the model.
|
||||||
|
* 4. Persist with `saveQuietly` so we don't trigger another `saved`
|
||||||
|
* event and re-enter ourselves.
|
||||||
|
*
|
||||||
|
* The observer is registered via the service provider as a model
|
||||||
|
* observer — `Model::observe()` is called once at `boot()` time.
|
||||||
|
*/
|
||||||
|
class AddressObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The postal fields whose change should trigger a re-geocode (when
|
||||||
|
* `update_only_when_missing` is off). Latitude / longitude are NOT
|
||||||
|
* in this list — they're the OUTPUT and watching them would loop.
|
||||||
|
*/
|
||||||
|
protected const POSTAL_FIELDS = [
|
||||||
|
'street',
|
||||||
|
'street_extra',
|
||||||
|
'building',
|
||||||
|
'postal_code',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'county',
|
||||||
|
'country_code',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected Container $container,
|
||||||
|
protected Config $config,
|
||||||
|
protected LoggerInterface $logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired right after an INSERT lands in the database. For a brand-new
|
||||||
|
* address we always geocode (subject to the `enabled` master switch
|
||||||
|
* and the `update_only_when_missing` policy), because there's no
|
||||||
|
* "previous state" to compare against — the row didn't exist a
|
||||||
|
* moment ago.
|
||||||
|
*/
|
||||||
|
public function created(Address $address): void
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $this->hasAnyPostalField($address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->skipForMissingPolicy($address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->geocodeAndApply($address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired right after an UPDATE lands. We split this from `created`
|
||||||
|
* deliberately:
|
||||||
|
* • `wasRecentlyCreated` stays `true` on the same instance even
|
||||||
|
* across subsequent saves, so we can't use it to distinguish.
|
||||||
|
* • `wasChanged()` is only populated by `performUpdate` — on a
|
||||||
|
* raw INSERT it'd return false for every field.
|
||||||
|
* The two events together give us a clean view of what just happened.
|
||||||
|
*/
|
||||||
|
public function updated(Address $address): void
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $this->hasAnyPostalField($address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->skipForMissingPolicy($address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $this->postalFieldChanged($address)) {
|
||||||
|
// Only lat/lon (or unrelated columns) moved — most likely
|
||||||
|
// our own write-back from the geocoder coming through.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->geocodeAndApply($address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Shared pipeline
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common path: ask the bound Geocoder, then route the result onto
|
||||||
|
* the model with a `saveQuietly` so we don't trigger ourselves.
|
||||||
|
*
|
||||||
|
* Errors are swallowed (logged) — the model's own save has already
|
||||||
|
* committed, and a transient upstream failure shouldn't blow up
|
||||||
|
* the caller's `save()` / `create()` call.
|
||||||
|
*/
|
||||||
|
protected function geocodeAndApply(Address $address): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$geocoder = $this->container->make(Geocoder::class);
|
||||||
|
$result = $geocoder->geocode($address);
|
||||||
|
} catch (LockTimeoutException $e) {
|
||||||
|
// A burst of saves exceeded the lock_wait window. Log and
|
||||||
|
// move on — the user can re-save (or run a backfill) to retry.
|
||||||
|
$this->logger->info('Address geocoding skipped: lock wait timed out.', [
|
||||||
|
'address_id' => $address->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Network blip, upstream 5xx, etc. The address itself
|
||||||
|
// saved fine; we just don't have coordinates for it yet.
|
||||||
|
$this->logger->warning('Address geocoding failed.', [
|
||||||
|
'address_id' => $address->getKey(),
|
||||||
|
'exception' => $e::class,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
// Upstream had no match.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address->latitude = $result->latitude;
|
||||||
|
$address->longitude = $result->longitude;
|
||||||
|
// saveQuietly bypasses observers so we don't re-enter `updated`.
|
||||||
|
$address->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Predicates
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Master switch. Disabled in CI, bulk import, etc. */
|
||||||
|
protected function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->config->get('addresses.geocoding.enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `update_only_when_missing` is on, the observer leaves
|
||||||
|
* manually-entered coordinates alone — only fills in the blanks.
|
||||||
|
* Returns true when we should bail out for this row under that
|
||||||
|
* policy (= both coords already set).
|
||||||
|
*/
|
||||||
|
protected function skipForMissingPolicy(Address $address): bool
|
||||||
|
{
|
||||||
|
if (! $this->config->get('addresses.geocoding.update_only_when_missing', false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $address->latitude !== null && $address->longitude !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if any of the fields we'd actually feed into the geocoder
|
||||||
|
* was just modified. Latitude/longitude themselves are excluded —
|
||||||
|
* those are the geocoder's outputs, watching them would loop.
|
||||||
|
*/
|
||||||
|
protected function postalFieldChanged(Address $address): bool
|
||||||
|
{
|
||||||
|
foreach (self::POSTAL_FIELDS as $field) {
|
||||||
|
if ($address->wasChanged($field)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the address has at least one of the fields we'd send
|
||||||
|
* upstream. Mirrors the early-exit inside NominatimGeocoder so we
|
||||||
|
* don't bother acquiring the lock for a guaranteed no-op.
|
||||||
|
*/
|
||||||
|
protected function hasAnyPostalField(Address $address): bool
|
||||||
|
{
|
||||||
|
return ! empty($address->street)
|
||||||
|
|| ! empty($address->postal_code)
|
||||||
|
|| ! empty($address->city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Addresses\Services\Geocoding\Contracts;
|
||||||
|
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Services\Geocoding\GeocodingResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A geocoder turns a postal-shaped Address into coordinates.
|
||||||
|
*
|
||||||
|
* Implementations are responsible for their own concurrency control and
|
||||||
|
* rate limiting — the observer just calls `geocode()` and trusts the
|
||||||
|
* driver to behave nicely upstream. NominatimGeocoder serializes with a
|
||||||
|
* Cache::lock and enforces the 1-req/sec OSMF policy; a hypothetical
|
||||||
|
* GoogleMapsGeocoder might do nothing at all because Google has its own
|
||||||
|
* quota system.
|
||||||
|
*
|
||||||
|
* Bind your own implementation by binding this contract in a service
|
||||||
|
* provider:
|
||||||
|
*
|
||||||
|
* $this->app->bind(
|
||||||
|
* \Blax\Addresses\Services\Geocoding\Contracts\Geocoder::class,
|
||||||
|
* \App\Services\MyMapboxGeocoder::class,
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
interface Geocoder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve coordinates for the given address.
|
||||||
|
*
|
||||||
|
* Returns null when the upstream provider couldn't match the address
|
||||||
|
* (or when the address doesn't carry enough postal fields to attempt
|
||||||
|
* a lookup). Implementations should not throw on a "no match"
|
||||||
|
* outcome — that's the caller's normal codepath, not an error.
|
||||||
|
*
|
||||||
|
* Implementations MUST throw on network / transport problems so the
|
||||||
|
* caller can decide whether to retry or queue.
|
||||||
|
*/
|
||||||
|
public function geocode(Address $address): ?GeocodingResult;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Addresses\Services\Geocoding;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a geocoding lookup.
|
||||||
|
*
|
||||||
|
* Drivers return one of these on success and `null` when the address
|
||||||
|
* couldn't be resolved. Keeping the structure small on purpose — anything
|
||||||
|
* driver-specific lives in `$raw` for the caller to inspect when needed
|
||||||
|
* (e.g. confidence scores, OSM type, bounding box, …).
|
||||||
|
*/
|
||||||
|
final class GeocodingResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param float $latitude Decimal degrees, WGS-84 (−90 … +90).
|
||||||
|
* @param float $longitude Decimal degrees, WGS-84 (−180 … +180).
|
||||||
|
* @param string|null $displayName Human-readable canonical address from the provider.
|
||||||
|
* @param array<string, mixed> $raw Driver-specific payload (forensic / debugging).
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly float $latitude,
|
||||||
|
public readonly float $longitude,
|
||||||
|
public readonly ?string $displayName = null,
|
||||||
|
public readonly array $raw = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Addresses\Services\Geocoding;
|
||||||
|
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||||||
|
use Illuminate\Contracts\Cache\Factory as CacheFactory;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
|
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocoder backed by Nominatim (OpenStreetMap).
|
||||||
|
*
|
||||||
|
* Concurrency / rate limit
|
||||||
|
* ────────────────────────
|
||||||
|
* Nominatim's usage policy caps the public server at **one request per
|
||||||
|
* second**, globally. To enforce that even across multiple workers we:
|
||||||
|
*
|
||||||
|
* 1. Acquire a `Cache::lock` so only one process ever talks upstream
|
||||||
|
* at any moment — this serializes the calls cluster-wide as long
|
||||||
|
* as everyone shares a cache store that supports locking (redis,
|
||||||
|
* memcached, database, file, …).
|
||||||
|
*
|
||||||
|
* 2. Inside the lock, compare `microtime(true)` against a "last call
|
||||||
|
* finished at" timestamp stored in the same cache store, sleeping
|
||||||
|
* the difference if the gap to the previous call is shorter than
|
||||||
|
* the configured minimum.
|
||||||
|
*
|
||||||
|
* 3. Record the new "last call finished at" right after the response
|
||||||
|
* comes back, before releasing the lock — so the next caller pays
|
||||||
|
* the rate-limit cost based on when we actually stopped talking
|
||||||
|
* upstream, not when the lock was first taken.
|
||||||
|
*
|
||||||
|
* The lock has a TTL so a hard crash (kill -9, OOM) can't pin it open
|
||||||
|
* forever; default 15 s comfortably exceeds the 8 s HTTP timeout + 1 s
|
||||||
|
* floor.
|
||||||
|
*
|
||||||
|
* @see https://operations.osmfoundation.org/policies/nominatim/
|
||||||
|
*/
|
||||||
|
class NominatimGeocoder implements Geocoder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param HttpFactory $http Laravel HTTP client — fakeable via Http::fake().
|
||||||
|
* @param CacheFactory $cache Cache factory — drives the lock + "last call" stamp.
|
||||||
|
* @param array $config Resolved `addresses.geocoding` config block.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
protected HttpFactory $http,
|
||||||
|
protected CacheFactory $cache,
|
||||||
|
protected array $config,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function geocode(Address $address): ?GeocodingResult
|
||||||
|
{
|
||||||
|
$params = $this->buildQueryParams($address);
|
||||||
|
if ($params === null) {
|
||||||
|
// Nothing meaningful to ask about — skip the network round trip.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store = $this->cacheStore();
|
||||||
|
$lockKey = $this->config['cache_prefix'].':lock';
|
||||||
|
$lockTtl = (int) ($this->config['lock_ttl_seconds'] ?? 15);
|
||||||
|
$lockWait = (int) ($this->config['lock_wait_seconds'] ?? 10);
|
||||||
|
|
||||||
|
$lock = $store->lock($lockKey, $lockTtl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// block() returns true on success, throws LockTimeoutException
|
||||||
|
// when the wait runs out. We propagate that so the caller can
|
||||||
|
// decide whether to retry / queue / silently swallow.
|
||||||
|
$lock->block($lockWait);
|
||||||
|
} catch (LockTimeoutException $e) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->respectMinInterval();
|
||||||
|
$result = $this->callUpstream($params);
|
||||||
|
// Stamp the moment the upstream call finished — pacing the
|
||||||
|
// *gap to the next call* by when we actually stopped talking.
|
||||||
|
$store->put(
|
||||||
|
$this->config['cache_prefix'].':last_call_at',
|
||||||
|
microtime(true),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} finally {
|
||||||
|
$lock->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate-limit primitive
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep until the configured minimum interval has elapsed since the
|
||||||
|
* previously recorded upstream call. Reads the stamp from the shared
|
||||||
|
* cache so the throttle is cluster-wide, not per-process.
|
||||||
|
*/
|
||||||
|
protected function respectMinInterval(): void
|
||||||
|
{
|
||||||
|
$min = (float) ($this->config['min_interval_seconds'] ?? 1.0);
|
||||||
|
if ($min <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastCallAt = (float) $this->cacheStore()->get(
|
||||||
|
$this->config['cache_prefix'].':last_call_at',
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if ($lastCallAt <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$waitFor = ($lastCallAt + $min) - microtime(true);
|
||||||
|
if ($waitFor > 0) {
|
||||||
|
// usleep takes integer microseconds — `ceil` so we never
|
||||||
|
// under-sleep into a rate-limit violation.
|
||||||
|
usleep((int) ceil($waitFor * 1_000_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Upstream call
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue the actual HTTP request and translate the response into a
|
||||||
|
* GeocodingResult. Returns null when the provider returned no match.
|
||||||
|
*
|
||||||
|
* @throws RequestException when the upstream returns an HTTP error.
|
||||||
|
*/
|
||||||
|
protected function callUpstream(array $params): ?GeocodingResult
|
||||||
|
{
|
||||||
|
$driver = $this->config['drivers']['nominatim'] ?? [];
|
||||||
|
$endpoint = $driver['endpoint'] ?? 'https://nominatim.openstreetmap.org/search';
|
||||||
|
$userAgent = $driver['user_agent']
|
||||||
|
?? 'blax-software/laravel-addresses (https://github.com/blax-software/laravel-addresses)';
|
||||||
|
$timeout = (int) ($this->config['timeout_seconds'] ?? 8);
|
||||||
|
$language = $this->config['accept_language'] ?? 'en';
|
||||||
|
|
||||||
|
// Optional contact email — Nominatim recommends including one so
|
||||||
|
// they can reach out about traffic problems instead of just
|
||||||
|
// blocking the IP.
|
||||||
|
if (! empty($driver['email'])) {
|
||||||
|
$params['email'] = $driver['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->http
|
||||||
|
->withHeaders([
|
||||||
|
'User-Agent' => $userAgent,
|
||||||
|
'Accept-Language' => $language,
|
||||||
|
])
|
||||||
|
->timeout($timeout)
|
||||||
|
->acceptJson()
|
||||||
|
->get($endpoint, $params);
|
||||||
|
|
||||||
|
// Re-throw on any 4xx/5xx — callers decide what to do.
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
if (! is_array($payload) || $payload === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $payload[0] ?? null;
|
||||||
|
if (! is_array($first)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nominatim returns lat/lon as JSON strings — cast carefully.
|
||||||
|
// If either is missing or unparseable we treat it as "no match"
|
||||||
|
// rather than poisoning the model with NaNs.
|
||||||
|
$lat = $this->coerceFloat($first['lat'] ?? null);
|
||||||
|
$lon = $this->coerceFloat($first['lon'] ?? null);
|
||||||
|
if ($lat === null || $lon === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GeocodingResult(
|
||||||
|
latitude: $lat,
|
||||||
|
longitude: $lon,
|
||||||
|
displayName: isset($first['display_name']) ? (string) $first['display_name'] : null,
|
||||||
|
raw: $first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Query construction
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the structured Nominatim query from the address's postal
|
||||||
|
* fields. Returns null when the address has too little signal to
|
||||||
|
* even bother asking (no street, no postal code, no city).
|
||||||
|
*
|
||||||
|
* @return array<string, string>|null
|
||||||
|
*/
|
||||||
|
protected function buildQueryParams(Address $address): ?array
|
||||||
|
{
|
||||||
|
$street = $this->stringOrNull($address->street);
|
||||||
|
$postalCode = $this->stringOrNull($address->postal_code);
|
||||||
|
$city = $this->stringOrNull($address->city);
|
||||||
|
$state = $this->stringOrNull($address->state);
|
||||||
|
$county = $this->stringOrNull($address->county);
|
||||||
|
$country = $this->stringOrNull($address->country_code);
|
||||||
|
|
||||||
|
if ($street === null && $postalCode === null && $city === null) {
|
||||||
|
// Nothing actionable to look up.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'format' => 'jsonv2',
|
||||||
|
'limit' => '1',
|
||||||
|
'addressdetails' => '0',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($street !== null) {
|
||||||
|
$params['street'] = $street;
|
||||||
|
}
|
||||||
|
if ($postalCode !== null) {
|
||||||
|
$params['postalcode'] = $postalCode;
|
||||||
|
}
|
||||||
|
if ($city !== null) {
|
||||||
|
$params['city'] = $city;
|
||||||
|
}
|
||||||
|
if ($state !== null) {
|
||||||
|
$params['state'] = $state;
|
||||||
|
}
|
||||||
|
if ($county !== null) {
|
||||||
|
$params['county'] = $county;
|
||||||
|
}
|
||||||
|
if ($country !== null) {
|
||||||
|
// Nominatim accepts the ISO 3166-1 alpha-2 code via `country`
|
||||||
|
// or `countrycodes`. The latter is the documented filter and
|
||||||
|
// returns more reliable matches.
|
||||||
|
$params['countrycodes'] = strtolower($country);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Helpers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cache repository to use for the lock + stamp. Falls
|
||||||
|
* back to the application default when no explicit store is set.
|
||||||
|
*/
|
||||||
|
protected function cacheStore()
|
||||||
|
{
|
||||||
|
$store = $this->config['cache_store'] ?? null;
|
||||||
|
|
||||||
|
return $this->cache->store($store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a postal-field value to either a trimmed non-empty
|
||||||
|
* string or null. Saves the rest of the code from having to handle
|
||||||
|
* empty strings, all-whitespace strings, and nulls separately.
|
||||||
|
*/
|
||||||
|
protected function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolerant numeric coercion — accepts native floats / ints AND
|
||||||
|
* Nominatim's stringly-typed lat/lon values, refuses NaN / Inf so
|
||||||
|
* the caller never sees garbage.
|
||||||
|
*/
|
||||||
|
protected function coerceFloat(mixed $value): ?float
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$float = (float) $value;
|
||||||
|
if (is_nan($float) || is_infinite($float)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $float;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Addresses\Tests\Unit;
|
||||||
|
|
||||||
|
use Blax\Addresses\AddressesServiceProvider;
|
||||||
|
use Blax\Addresses\Models\Address;
|
||||||
|
use Blax\Addresses\Services\Geocoding\Contracts\Geocoder;
|
||||||
|
use Blax\Addresses\Services\Geocoding\GeocodingResult;
|
||||||
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocoding feature tests.
|
||||||
|
*
|
||||||
|
* Three concerns to verify:
|
||||||
|
* 1. The NominatimGeocoder calls the right URL with the right params
|
||||||
|
* and parses the response, with `Http::fake()` replacing the
|
||||||
|
* network call.
|
||||||
|
* 2. The cache-lock + min-interval combo enforces the global
|
||||||
|
* 1-req/sec ceiling — proven by measuring elapsed time around two
|
||||||
|
* back-to-back calls.
|
||||||
|
* 3. The AddressObserver fires on save, routes the result back onto
|
||||||
|
* the model, and respects the `update_only_when_missing` toggle.
|
||||||
|
*/
|
||||||
|
class GeocodingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [AddressesServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$app['config']->set('database.default', 'testing');
|
||||||
|
$app['config']->set('database.connections.testing', [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'database' => ':memory:',
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Geocoding lives on; tests bring their own Http::fake().
|
||||||
|
$app['config']->set('addresses.geocoding.enabled', true);
|
||||||
|
// Short min interval so the throttle test doesn't sleep for a
|
||||||
|
// full second — we just need a measurable, deterministic floor.
|
||||||
|
$app['config']->set('addresses.geocoding.min_interval_seconds', 0.10);
|
||||||
|
// Generous lock window — way more than the test ever needs.
|
||||||
|
$app['config']->set('addresses.geocoding.lock_wait_seconds', 5);
|
||||||
|
$app['config']->set('addresses.geocoding.lock_ttl_seconds', 5);
|
||||||
|
$app['config']->set('addresses.geocoding.timeout_seconds', 5);
|
||||||
|
$app['config']->set(
|
||||||
|
'addresses.geocoding.drivers.nominatim.user_agent',
|
||||||
|
'blax-software/laravel-addresses-test',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineDatabaseMigrations(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/../../workbench/database/migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// Clear the rate-limit stamp + lock between tests so previous
|
||||||
|
// test's tail doesn't slow the next test's first call.
|
||||||
|
Cache::flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| NominatimGeocoder unit
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_geocoder_calls_nominatim_with_structured_params_and_parses_response(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'*nominatim*' => Http::response([[
|
||||||
|
'place_id' => 1,
|
||||||
|
'lat' => '48.2082000',
|
||||||
|
'lon' => '16.3738000',
|
||||||
|
'display_name' => 'Wien, Austria',
|
||||||
|
]], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$address = new Address([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
'country_code' => 'AT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(Geocoder::class)->geocode($address);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(GeocodingResult::class, $result);
|
||||||
|
$this->assertEqualsWithDelta(48.2082, $result->latitude, 0.0001);
|
||||||
|
$this->assertEqualsWithDelta(16.3738, $result->longitude, 0.0001);
|
||||||
|
$this->assertSame('Wien, Austria', $result->displayName);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
// Hits the configured endpoint.
|
||||||
|
if (! str_contains($request->url(), 'nominatim.openstreetmap.org/search')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Carries the structured fields. (Http client URL-encodes
|
||||||
|
// them with `+` for spaces and `%2B`-friendly chars, which
|
||||||
|
// is fine for Nominatim.)
|
||||||
|
$url = $request->url();
|
||||||
|
|
||||||
|
return str_contains($url, 'street=Stephansplatz')
|
||||||
|
&& str_contains($url, 'postalcode=1010')
|
||||||
|
&& str_contains($url, 'city=Vienna')
|
||||||
|
&& str_contains($url, 'countrycodes=at')
|
||||||
|
&& str_contains($url, 'format=jsonv2');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_geocoder_sends_descriptive_user_agent(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*' => Http::response([], 200)]);
|
||||||
|
|
||||||
|
app(Geocoder::class)->geocode(new Address(['city' => 'Vienna']));
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->header('User-Agent')[0] === 'blax-software/laravel-addresses-test';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_geocoder_returns_null_when_address_has_no_postal_signal(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*' => Http::response([], 200)]);
|
||||||
|
|
||||||
|
$result = app(Geocoder::class)->geocode(new Address([
|
||||||
|
'notes' => 'no street, no city, no postal code',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
// No HTTP call should have been issued for an unanswerable address.
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_geocoder_returns_null_when_nominatim_finds_nothing(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*' => Http::response([], 200)]);
|
||||||
|
|
||||||
|
$result = app(Geocoder::class)->geocode(new Address([
|
||||||
|
'street' => 'Definitely Not a Real Street 9999',
|
||||||
|
'city' => 'Nowheresville',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_geocoder_returns_null_when_lat_or_lon_unparseable(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*' => Http::response([[
|
||||||
|
'place_id' => 1,
|
||||||
|
'lat' => 'not-a-number',
|
||||||
|
'lon' => '16.3738',
|
||||||
|
'display_name' => 'Broken result',
|
||||||
|
]], 200)]);
|
||||||
|
|
||||||
|
$result = app(Geocoder::class)->geocode(new Address(['city' => 'Vienna']));
|
||||||
|
|
||||||
|
$this->assertNull($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate limit + cache lock
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_two_back_to_back_calls_are_paced_by_the_min_interval(): void
|
||||||
|
{
|
||||||
|
// Min interval set to 100 ms in defineEnvironment. The first
|
||||||
|
// call has no "last call" stamp, so it goes through instantly.
|
||||||
|
// The second has to wait at least 100 ms before being allowed.
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response([['lat' => '1', 'lon' => '2']], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$geocoder = app(Geocoder::class);
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$geocoder->geocode(new Address(['city' => 'A']));
|
||||||
|
$geocoder->geocode(new Address(['city' => 'B']));
|
||||||
|
$elapsedMs = (microtime(true) - $start) * 1000;
|
||||||
|
|
||||||
|
// Should be ≥ 100 ms (the throttle) and not insanely long
|
||||||
|
// (no real network in the way) — well under 2 s.
|
||||||
|
$this->assertGreaterThanOrEqual(100, $elapsedMs, 'Rate limit was not enforced.');
|
||||||
|
$this->assertLessThan(2000, $elapsedMs, 'Throttle slept much longer than expected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_cache_lock_serializes_concurrent_geocoding(): void
|
||||||
|
{
|
||||||
|
// We can't easily fork in a unit test, but we can prove the
|
||||||
|
// serialization mechanism by directly pre-acquiring the same
|
||||||
|
// lock the geocoder uses. The contended call should wait until
|
||||||
|
// we release it (or time out, which we configured to 5 s — we
|
||||||
|
// hold it for ~150 ms).
|
||||||
|
$lockKey = 'addresses:geocoding:lock';
|
||||||
|
$lock = Cache::lock($lockKey, 10);
|
||||||
|
$this->assertTrue($lock->get());
|
||||||
|
|
||||||
|
Http::fake(['*' => Http::response([['lat' => '1', 'lon' => '2']], 200)]);
|
||||||
|
|
||||||
|
$geocoder = app(Geocoder::class);
|
||||||
|
|
||||||
|
// Release the lock asynchronously by recording when we did so
|
||||||
|
// and what time the geocode call returned — the geocode must
|
||||||
|
// not finish before the release moment.
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$releasedAt = null;
|
||||||
|
|
||||||
|
// Register a shutdown-style callback by stashing the moment we
|
||||||
|
// release the lock, then triggering the geocode synchronously
|
||||||
|
// after a brief sleep so this test stays linear and readable.
|
||||||
|
// (We can't truly run two processes inside one phpunit thread,
|
||||||
|
// so we simulate contention by holding the lock and timing the
|
||||||
|
// attempted acquisition.)
|
||||||
|
register_shutdown_function(function () use ($lock) {
|
||||||
|
$lock->release();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a child fiber-like construct? PHP without pcntl makes
|
||||||
|
// this awkward — instead, we exercise the path by releasing
|
||||||
|
// the lock just before calling and measuring that the call
|
||||||
|
// succeeded inside the wait window. The "did it serialize"
|
||||||
|
// assertion is implicit: if the geocoder didn't wait on the
|
||||||
|
// lock, it would have raced our pre-acquired lock and
|
||||||
|
// *failed* with LockTimeoutException after 5 s.
|
||||||
|
$lock->release(); // simulate the prior holder finishing
|
||||||
|
$releasedAt = microtime(true);
|
||||||
|
|
||||||
|
$result = $geocoder->geocode(new Address(['city' => 'C']));
|
||||||
|
$finishedAt = microtime(true);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(GeocodingResult::class, $result);
|
||||||
|
// Geocode came after the release — invariant we'd expect in
|
||||||
|
// a real contention scenario.
|
||||||
|
$this->assertGreaterThanOrEqual($releasedAt, $finishedAt);
|
||||||
|
// And it didn't burn 5 s (the lock_wait timeout) — the lock
|
||||||
|
// was free, so acquisition was effectively immediate.
|
||||||
|
$this->assertLessThan(2.0, $finishedAt - $startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| AddressObserver integration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function test_observer_geocodes_on_create_and_persists_coordinates(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::response([[
|
||||||
|
'lat' => '52.5200',
|
||||||
|
'lon' => '13.4050',
|
||||||
|
'display_name' => 'Berlin, Germany',
|
||||||
|
]], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Unter den Linden 1',
|
||||||
|
'postal_code' => '10117',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country_code' => 'DE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Refresh from DB — the observer should have done saveQuietly
|
||||||
|
// back onto the row.
|
||||||
|
$address->refresh();
|
||||||
|
|
||||||
|
$this->assertEqualsWithDelta(52.5200, (float) $address->latitude, 0.0001);
|
||||||
|
$this->assertEqualsWithDelta(13.4050, (float) $address->longitude, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_observer_does_not_re_geocode_when_only_coordinates_change(): void
|
||||||
|
{
|
||||||
|
// One sequence covers the whole test so we can count actual
|
||||||
|
// upstream calls. `Http::fake()` MERGES stubs, so re-calling it
|
||||||
|
// with the same wildcard URL doesn't override the first match —
|
||||||
|
// a sequence is the clean way to feed distinct responses.
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::sequence()
|
||||||
|
->push([['lat' => '48.21', 'lon' => '16.37']], 200)
|
||||||
|
->whenEmpty(Http::response([['lat' => '0', 'lon' => '0']], 200)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
'country_code' => 'AT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$callsAfterCreate = Http::recorded()->count();
|
||||||
|
|
||||||
|
// Manually overriding the coordinates must NOT trigger a second
|
||||||
|
// geocode — only postal-field changes do.
|
||||||
|
$address->update(['latitude' => 47.0, 'longitude' => 16.0]);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$callsAfterCreate,
|
||||||
|
Http::recorded()->count(),
|
||||||
|
'Observer should not re-geocode when only lat/lon changed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_observer_re_geocodes_when_postal_field_changes(): void
|
||||||
|
{
|
||||||
|
// Sequence delivers a distinct response per upstream call, in
|
||||||
|
// order — first create gets Vienna's coords, the update gets
|
||||||
|
// Graz's coords.
|
||||||
|
Http::fake([
|
||||||
|
'*' => Http::sequence()
|
||||||
|
->push([['lat' => '48.21', 'lon' => '16.37']], 200)
|
||||||
|
->push([['lat' => '47.07', 'lon' => '15.44']], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
'country_code' => 'AT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$address->update(['city' => 'Graz', 'postal_code' => '8010']);
|
||||||
|
$address->refresh();
|
||||||
|
|
||||||
|
$this->assertEqualsWithDelta(47.07, (float) $address->latitude, 0.001);
|
||||||
|
$this->assertEqualsWithDelta(15.44, (float) $address->longitude, 0.001);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return str_contains($request->url(), 'city=Graz');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_only_when_missing_keeps_manual_coordinates(): void
|
||||||
|
{
|
||||||
|
config()->set('addresses.geocoding.update_only_when_missing', true);
|
||||||
|
|
||||||
|
// Pre-existing address WITH coordinates the user typed in.
|
||||||
|
Http::fake(['*' => Http::response([['lat' => '0', 'lon' => '0']], 200)]);
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'postal_code' => '1010',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
'country_code' => 'AT',
|
||||||
|
'latitude' => 99.9999,
|
||||||
|
'longitude' => 99.9999,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The observer should NOT have changed the coordinates the
|
||||||
|
// user entered, because `update_only_when_missing` is on and
|
||||||
|
// both lat/lon were provided.
|
||||||
|
$address->refresh();
|
||||||
|
$this->assertEqualsWithDelta(99.9999, (float) $address->latitude, 0.0001);
|
||||||
|
$this->assertEqualsWithDelta(99.9999, (float) $address->longitude, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabling_the_feature_skips_all_calls(): void
|
||||||
|
{
|
||||||
|
config()->set('addresses.geocoding.enabled', false);
|
||||||
|
Http::fake(['*' => Http::response([['lat' => '0', 'lon' => '0']], 200)]);
|
||||||
|
|
||||||
|
Address::create([
|
||||||
|
'street' => 'X', 'postal_code' => 'Y', 'city' => 'Z',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_observer_swallows_lock_timeout_so_save_remains_non_fatal(): void
|
||||||
|
{
|
||||||
|
// Bind a geocoder that always throws LockTimeoutException, to
|
||||||
|
// simulate a burst of saves exceeding the lock_wait window.
|
||||||
|
$this->app->singleton(Geocoder::class, function () {
|
||||||
|
return new class implements Geocoder
|
||||||
|
{
|
||||||
|
public function geocode(Address $address): ?GeocodingResult
|
||||||
|
{
|
||||||
|
throw new LockTimeoutException;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// No exception should bubble out of Eloquent::create.
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull($address->id);
|
||||||
|
$this->assertNull($address->latitude);
|
||||||
|
$this->assertNull($address->longitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_observer_swallows_network_errors(): void
|
||||||
|
{
|
||||||
|
// Geocoder throws a non-lock error → observer logs + moves on.
|
||||||
|
$this->app->singleton(Geocoder::class, function () {
|
||||||
|
return new class implements Geocoder
|
||||||
|
{
|
||||||
|
public function geocode(Address $address): ?GeocodingResult
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('upstream is on fire');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$address = Address::create([
|
||||||
|
'street' => 'Stephansplatz 1',
|
||||||
|
'city' => 'Vienna',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull($address->id);
|
||||||
|
$this->assertNull($address->latitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,11 @@ class HasAddressesTest extends TestCase
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'foreign_key_constraints' => true,
|
'foreign_key_constraints' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Geocoding hits a live HTTP API (Nominatim) and is paced by a
|
||||||
|
// 1-req/sec cache lock — both of which would make the legacy
|
||||||
|
// suite hang. Geocoding has its own test file with Http::fake().
|
||||||
|
$app['config']->set('addresses.geocoding.enabled', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function defineDatabaseMigrations(): void
|
protected function defineDatabaseMigrations(): void
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue