Photo by Joey Huang on Unsplash
Ciao, eccoci di nuovo: nella prima parte (disponibile qui, se te la sei persa) ho impostato la struttura del package con asset e migrazioni, qui inizierò a scrivere il codice vero e proprio con model, controller, route e view per gestire la logica effettiva del package.
Come funziona la registrazione?
Le registrazioni devono essere molto trasparenti per il sistema: chi usa il package non dovrebbe perdere tempo a integrare le registrazioni nel proprio sistema. Per seguire la filosofia KISS, la struttura della registrazione dovrebbe essere composta da pochi semplici passaggi!
Spyhole registrerà le sessioni utente usando RRWeb nel frontend dell'utente in modo totalmente invisibile. Le registrazioni verranno raccolte in una collection composta da un numero minimo di eventi (impostabile dalla config) che verrà inviata per essere memorizzata.
Il Model
I model del package dovrebbero stare nella cartella src/Models (la cartella src è come la cartella app di Laravel). Il model dovrebbe semplicemente "tradurre" la migrazione nella versione Eloquent. Ho deciso di aggiungere un po' di PHPDoc per facilitare la lettura e dare un minimo di supporto all'IDE. Una piccola nota: le registrazioni utente arrivano dal frontend e verranno memorizzate "così come sono", quindi ho deciso di codificare in base64 l'intero payload JSON proveniente dal frontend dopo averlo compresso con gzip! Questa operazione può essere semplificata e resa trasparente all'uso del model tramite i mutator di Eloquent.
<?php
namespace Kalizi\LaravelSpyhole\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Class SessionRecording
*
* @property int id
* @property string path
* @property string session_id
* @property array recordings
* @property string|null user_id
* @package Kalizi\LaravelSpyhole\Models
*/
class SessionRecording extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'session_recordings';
public function getRecordingsAttribute()
{
return json_decode(gzdecode(base64_decode($this->attributes['recordings'])));
}
public function setRecordingsAttribute($value)
{
$this->attributes['recordings'] = base64_encode(gzencode(json_encode($value)));
}
}
"Ciao, qui è il TDD!" 🎯
Photo by JESHOOTS.COM on Unsplash
La prima volta che mi sono avvicinato da solo al test-driven development pensavo "perché c@*#o devo farlo?" e non posso biasimare chi ha la stessa reazione. Ma studiando, entrando in contatto con persone che usano regolarmente il TDD e dopo tanti tentativi, inizi a pensare "beh, non era poi così male" (almeno nel mio caso).
Quando posso, e quando voglio, uso il TDD nei miei progetti! Nel mondo dello sviluppo ci sono tanti modi di approcciare il TDD. Dopo tanti test, ho deciso di attenermi a questo modo di lavorare:
- Inizi a progettare il flusso del tuo codice senza scrivere codice: devi avere un'idea chiara della feature che vuoi implementare, cosa deve ricevere come parametri e cosa deve restituire come output.
- Scrivi i test: ho imparato questa tecnica da Linkedin. A prima vista pensavo "ma che roba è? Non hai nemmeno iniziato a scrivere codice e devi già testare?" e... beh sì, ma no. Lo scopo di scrivere i test prima del codice è costringerti a ragionare a fondo su come funziona la tua feature.
- Esegui i test: non hai mai scritto codice, i test devono fallire, quindi perché farlo? Beh, serve a testare il tuo motore. Immagina che i test passino... ci sarebbe un problema, no?
- Scrivi il codice: è la parte in cui implementi la tua feature. A questo punto dovresti avere un'idea chiara di cosa scrivere. Questo non significa che non puoi cambiare idea su alcune feature, ma in generale ragionare così dovrebbe aiutarti.
- Esegui i test: e questo passaggio ti dirà se hai fatto un ottimo lavoro o se devi correggere qualcosa.
Un altro vantaggio è che aiuta a suddividere i compiti: puoi scrivere codice mentre un'altra persona scrive i test.
Analizzando Spyhole, il package dovrebbe avere una route dove fare il POST dei dati, un Controller per gestire il salvataggio e una Request per validare i dati.
Il Controller dovrebbe salvare le registrazioni con il relativo path. Ogni volta che il frontend salva nuove registrazioni, il controller accoderà le nuove registrazioni (questo significa che, una volta creata una sessione di registrazione, il suo identificativo dovrebbe essere restituito al frontend). Il package può essere configurato per salvare l'user ID insieme alle registrazioni, e anche questo va testato. Infine, ma non meno importante, il session ID dalla classe Session per il tracciamento, un altro test da fare.
Da questa analisi, ho pensato che i test da fare fossero 4:
- Un test per verificare che la prima request crei una nuova registrazione.
- Un test per verificare che una nuova request accodi le registrazioni a quella esistente.
- Un test per verificare che l'user ID venga salvato se l'opzione è abilitata.
- Un test per verificare che il session ID venga salvato se l'opzione è abilitata.
Prima di iniziare: il testing di Laravel, come comportamento predefinito, non avvia le sessioni con PHPUnit, quindi va abilitato tramite il setup.
protected function setUp(): void
{
parent::setUp();
$kernel = app('Illuminate\Contracts\Http\Kernel');
$kernel->pushMiddleware('Illuminate\Session\Middleware\StartSession');
}Test #1: creare una nuova registrazione
Lo scopo di questo test è inviare dei dati fittizi e verificare che:
- il session ID non venga salvato, a favore di un ID fittizio.
- l'ID fittizio venga mantenuto tra chiamate multiple.
- l'user ID non venga tracciato.
- la registrazione venga salvata e il suo ID venga restituito.
<?php
/**
* This test check if can correctly store a request with recording.
* @test
*/
public function can_store_first_recording_request()
{
$this->assertFalse(config('laravel-spyhole.track_request_session_id'));
$requestData = [
'frames' => [
// some example data
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
],
'path' => '/',
];
$response = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$response->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$this->assertTrue($response->json('success'));
$this->assertIsNumeric($recordingId = decrypt($response->json('recording')));
$this->assertDatabaseHas(
'session_recordings',
[
'id' => (int)$recordingId,
'path' => '/',
'recordings' => base64_encode(gzencode(json_encode($requestData['frames']))),
'user_id' => null,
]
);
$recording = SessionRecording::find((int) $recordingId);
$this->assertNotEquals($this->app['session']->getId(), $recording->session_id);
}
Riguardo a questo test:
- Mi aspetto che la request contenga una chiave
framescon i dati delle registrazioni da RRWeb e una chiavepathrelativa al path corrente dell'utente. - Mi aspetto che la response restituisca una chiave
successper verificare che tutto sia andato bene e una chiaverecordingcon l'ID cifrato. - Mi aspetto che i frame siano codificati e compressi con gzip a partire dal JSON iniziale.
- Mi aspetto che il session ID salvato sia generato casualmente e diverso dall'ID reale.
Test #2: accodare registrazioni a un record esistente
Lo scopo di questo test è inviare dei dati fittizi e un recording ID cifrato e verificare che
- l'user ID continui a non essere tracciato.
- le registrazioni vengano unite.
<?php
/**
* This test check if can correctly store frames into the same row of an existing session.
* @test
*/
public function can_store_frames_for_a_started_session()
{
$recording = new SessionRecording();
$recording->recordings = [
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
];
$recording->path = '/';
$recording->session_id = Str::uuid();
$recording->save();
$requestData = [
'frames' => [
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
],
'path' => '/',
'recording' => encrypt($recording->id),
];
$response = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$response->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$this->assertTrue($response->json('success'));
$this->assertIsNumeric($recordingId = decrypt($response->json('recording')));
$this->assertDatabaseHas(
'session_recordings',
[
'id' => (int)$recordingId,
'path' => '/',
'recordings' => base64_encode(gzencode(json_encode(array_merge(
$recording->recordings,
$requestData['frames']
)))),
'user_id' => null,
]
);
}
Riguardo a questo test:
- Mi aspetto che la request contenga una chiave
framescon i dati delle registrazioni da RRWeb, una chiavepathrelativa al path corrente dell'utente e una chiaverecordingcon l'ID cifrato della request precedente. - Mi aspetto che la response restituisca una chiave
successper verificare che tutto sia andato bene e una chiaverecordingcon l'ID cifrato. - Mi aspetto che le registrazioni di questa request siano codificate e compresse con gzip, poi unite alle precedenti.
Test #3: salvare le registrazioni con l'utente attualmente loggato
Lo scopo di questo test è inviare dei dati fittizi e verificare che
- l'user ID venga tracciato.
<?php
/**
* This test check if can correctly store the user id while the configuration option is enabled.
* @test
*/
public function can_store_recording_with_logged_in_user()
{
config()->set('laravel-spyhole.record_user_id', true);
// Mock a fake user
$user = new FakeUser();
$user->id = rand(0, 1000);
Auth::shouldReceive('user')->andReturn($user)->once();
$requestData = [
'frames' => [
// some example data
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
],
'path' => '/',
];
$response = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$response->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$this->assertTrue($response->json('success'));
$this->assertIsNumeric($recordingId = decrypt($response->json('recording')));
$this->assertDatabaseHas(
'session_recordings',
[
'id' => (int)$recordingId,
'path' => '/',
'recordings' => base64_encode(gzencode(json_encode($requestData['frames']))),
'user_id' => $user->id,
]
);
}
class FakeUser implements Authenticatable
{
/**
* @var int $id Fake Identifier
*/
public $id;
public function getAuthIdentifierName(): string
{
return 'test';
}
public function getAuthIdentifier(): int
{
return $this->id;
}
public function getAuthPassword(): string
{
return 'password';
}
public function getRememberToken(): string
{
return '';
}
public function setRememberToken($value)
{
}
public function getRememberTokenName(): string
{
return '';
}
}
Riguardo a questo test:
- Mi aspetto che la request sia esattamente come quella testata in precedenza.
- Mi aspetto che nella config il tracciamento dell'utente sia attivato.
- Mi aspetto che l'user ID venga salvato nel record del database.
- L'autenticazione è mockata tramite
Auth::shouldReceive, il che forzerà l'uso del metodoAuth::user.
Test #4: salvare il session ID dalla Session di Laravel
Lo scopo di questo test è inviare dei dati fittizi e verificare che
- il session ID reale venga tracciato.
<?php
/**
* This test check if can correctly store the user id while the configuration option is enabled.
* @test
*/
public function can_store_correct_session_id()
{
config()->set('laravel-spyhole.track_request_session_id', true);
$requestData = [
'frames' => [
// some example data
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
],
'path' => '/',
];
$response = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$response->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$this->assertTrue($response->json('success'));
$this->assertIsNumeric($recordingId = decrypt($response->json('recording')));
$this->assertDatabaseHas(
'session_recordings',
[
'id' => (int)$recordingId,
'path' => '/',
'recordings' => base64_encode(gzencode(json_encode($requestData['frames']))),
'user_id' => null,
'session_id' => $this->app['session']->getId()
]
);
}
Riguardo a questo test:
- Mi aspetto che la request sia esattamente come quella testata in precedenza.
- Mi aspetto che nella config il tracciamento della sessione sia attivato.
- Mi aspetto che il session ID venga salvato nel record del database.
Test #5: l'ID di sessione fittizio viene mantenuto tra chiamate multiple
Lo scopo di questo test è inviare due volte dei dati fittizi verso path diversi e verificare che
- il session ID fittizio venga tracciato tra le due chiamate.
<?php
/**
* This test check if the fake session id is kept between calls.
* @test
*/
public function can_store_recordings_keeping_generated_session_id()
{
$this->assertFalse(config('laravel-spyhole.track_request_session_id'));
$requestData = [
'frames' => [
// some example data
[
'timestamp' => now()->unix(),
'data' => [
'x' => 0,
'y' => 0,
'type' => 0
]
]
],
'path' => '/',
];
$response = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$response->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$recordingId = decrypt($response->json('recording'));
$recording = SessionRecording::find((int) $recordingId);
$this->assertNotEquals($this->app['session']->getId(), $recording->session_id);
$requestData['path'] = '/path_changed';
$secondResponse = $this->json(
'POST',
route('spyhole.store-entry'),
$requestData
);
$secondResponse->assertSuccessful();
$response->assertJsonStructure([
'success',
'recording',
]);
$this->assertDatabaseHas(
'session_recordings',
[
'id' => (int)$recordingId,
'path' => '/',
'recordings' => base64_encode(gzencode(json_encode($requestData['frames']))),
'user_id' => null,
'session_id' => $recording->session_id
]
);
}
Riguardo a questo test:
- Mi aspetto che la prima request venga accettata.
- Mi aspetto che la seconda request venga accettata e mantenga lo stesso session ID.
La logica del controller 📔
Ora la cosa si fa piuttosto divertente, perché la logica del controller si deduce dal codice dei test.
I payload dei dati hanno la stessa struttura, che può essere validata tramite una Request. L'autorizzazione della Request può essere concessa solo se il contenuto della request viene passato come JSON, e questo si può ottenere usando wantsJson in authorize.
public function authorize(): bool
{
return $this->wantsJson();
}
Per il body, vogliamo validare i tre campi individuati.
public function rules(): array
{
return [
// recording frames
'frames' => 'required|array',
// previous recording id (encrypted)
'recording' => 'sometimes|string',
// recorded path
'path' => 'required|string',
];
}
Ora che la request è pronta, concentriamoci sul controller.
<?php
namespace Kalizi\LaravelSpyhole\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Kalizi\LaravelSpyhole\Http\Requests\StoreEntryRequest;
use Kalizi\LaravelSpyhole\Models\SessionRecording;
use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException;
class EntryController extends Controller
{
public function store(StoreEntryRequest $request): JsonResponse
{
$recordingId = null;
if ($request->has('recording')) {
$recordingId = (int)decrypt($request->get('recording'));
if (SessionRecording::whereId($recordingId)->count() === 0) {
throw new NotAcceptableHttpException();
}
}
if (config('laravel-spyhole.track_request_session_id')) {
$sessionId = $request->session()->getId();
} else {
if (session()->has('spyhole_session_id')) {
$sessionId = session()->get('spyhole_session_id');
} else {
do {
$sessionId = Str::uuid()->toString();
} while (
SessionRecording::whereSessionId($sessionId)->count() > 0 &&
$sessionId !== $request->session()->getId()
);
session()->put('spyhole_session_id', $sessionId);
}
}
$userId = null;
if (config('laravel-spyhole.record_user_id')) {
$user = Auth::user();
$userId = $user ? $user->getAuthIdentifier() : null;
}
if ($recordingId === null) {
$recording = new SessionRecording();
$recording->session_id = $sessionId;
$recording->user_id = $userId;
$recording->path = $request->get('path');
$recording->recordings = $request->get('frames');
} else {
$recording = SessionRecording::wherePath($request->get('path'))
->whereId($recordingId)
->first();
if ($recording === null) {
throw new NotAcceptableHttpException();
}
// Merge frames from the same session
$recording->recordings = array_merge(
$recording->recordings,
$request->get('frames')
);
}
$recording->save();
return response()->json([
'success' => true,
'recording' => encrypt($recording->id),
]);
}
}
La logica del controller è molto pulita e non dovresti avere problemi a leggerla. Ogni volta che un dato non è valido, viene lanciata una Not Acceptable Exception.
Ora che il controller è implementato, puoi eseguire la test suite!
$ ./vendor/bin/phpunit --testdox
PHPUnit 8.5.14 by Sebastian Bergmann and contributors.
Runtime: PHP 8.0.1
Configuration: ./phpunit.xml.dist
Warning - The configuration file did not pass validation!
The following problems have been detected:
Line 25:
- Element 'log', attribute 'charset': The attribute 'charset' is not allowed.
- Element 'log', attribute 'yui': The attribute 'yui' is not allowed.
- Element 'log', attribute 'highlight': The attribute 'highlight' is not allowed.
Test results may not be as expected.
Error: This version of PHPUnit does not support code coverage on PHP 8
Store (Kalizi\LaravelSpyhole\Tests\Http\Store)
✔ Can store first recording request 450 ms
✔ Can store recordings keeping generated session id 39 ms
✔ Can store frames for a started session 38 ms
✔ Can store recording with logged in user 45 ms
✔ Can store correct session id 32 ms
Time: 16.26 seconds, Memory: 24.00 MB
OK (4 tests, 27 assertions)
Test superati! 🎯
La route 🗾
Controller e Request sono pronti e testati, ma al momento nessuno ne è a conoscenza perché non sono esposti. Collegare route e controller è davvero semplice e funziona esattamente come faresti in un progetto Laravel.
Tutto parte dal file delle route in src/routes.php, dove la route può essere dichiarata usando la Facade Route:
use Kalizi\LaravelSpyhole\Http\Controllers\EntryController;
Route::post('/spyhole-api/record', [EntryController::class, 'store'])->name('spyhole.store-entry');
Ho deciso di usare route con nome, così che ogni route sia accessibile tramite l'helper route e gli URL possano essere modificati se necessario.
Ma il package non sa che abbiamo delle route: il file delle route deve essere segnalato nel Service Provider, in questo modo:
$this->loadRoutesFrom(__DIR__ . '/routes.php');
Ed è tutto, le route sono pronte all'uso!
Prossimo passo?
Beh, ora che il backend è pronto, ciò che manca è il frontend: nel prossimo passo costruirò il JS da incorporare in qualsiasi pagina per registrare tutto, collegandolo al controller appena creato!
Resta sintonizzato per la serie completa e, se vuoi, prenditi un momento per lasciare un commento su come avresti fatto la progettazione o se avresti cambiato qualcosa in questo passaggio! ☕️
✔️ Migliorare la validazione della request ✔️
Per costruire il package più velocemente, mentre leggevi la prima versione della classe Request, ho semplicemente messo la regola di validazione "array" per la chiave delle registrazioni. Funziona e basta. Ma... va bene? No. È l'opposto della security by design.
Miglioriamo questa cosa.
RRWeb usa il Mutation Watcher per serializzare il DOM e i suoi cambiamenti, quindi parte del payload della request può essere "ricostruita" a partire dalla documentazione sulla serializzazione.
Quindi ho deciso di prendere un piccolo insieme di registrazioni (400 eventi) per estrarne uno schema usando JSONSchema.net.
Estrazione del JSON Schema
JSON Schema is a vocabulary that allows you to annotate and validate JSON documents.
Laravel non ha una validazione JSON Schema integrata: puoi sicuramente usare un package aggiuntivo, ma Laravel Validation offre già tutto ciò di cui ho bisogno. Quindi perché usare JSON Schema? Per estrarre un pattern.
Il JSON Schema come grafo
La radice del grafo è descritta con il tipo "array", fin qui tutto ok. Ora espandila: sostanzialmente contiene solo gli items che vogliamo validare, additionalItems viene aggiunto per esplicitare che tutto ciò che è diverso dalla prima chiave è valido. Ora concentrati su ciascun item. Questo schema è davvero semplice, dice solo che ogni item, deducendo il pattern da tutti i dati forniti, è composto da:
type: un intero. La sua regola dovrebbe essere'frames.*.type' => 'required|integer'.timestamp: una stringa con un tempo UNIX. Laravel non fornisce la validazione dei timestamp di default. In ogni caso, una regola può essere aggiunta tramite una funzione. La sua regola dovrebbe essere:
'frames.*.timestamp' => [
'required',
'integer',
function ($attribute, $value, $parameters) {
try {
return Carbon::createFromTimestamp($value)->isCurrentHour();
} catch (\Exception $invalidTimestampException) {
return false;
}
}
],
additionalProperties: comeadditionalItems, accetterebbe qualsiasi proprietà non inclusa nello schema, ma si può semplicemente saltare.data: qui c'è il grosso problema. Questa chiave array è estremamente mutevole, dato che RRWeb ha tantissimi dati da inviare: dati sulla larghezza e l'altezza del browser, dati serializzati sui nodi e i loro cambiamenti, gli eventi dell'utente, e tutti questi dati sono diversi, quindi l'unica regola utilizzabile è "array":'frames.*.data' => 'required|array'.
Ricorda che i dati non vengono salvati nel database così come sono: vengono compressi con gzip e codificati in base64. Questa modifica può essere considerata una piccola correzione e non dovrebbe rompere i test, quindi eseguirli non dovrebbe dare alcun problema!