From 5ea5c045a70cf14a1ebc6fb7d6cd852df9b17b18 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 06:58:03 -0300 Subject: [PATCH] feat: source tokens and client events API Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- core/app/Console/Commands/SourceCreate.php | 31 +++++ .../Http/Controllers/Api/EventsController.php | 80 ++++++++++++ .../Http/Middleware/AuthenticateSource.php | 28 +++++ core/app/Http/Middleware/RemoteUser.php | 15 +++ .../app/Http/Requests/UpsertEventsRequest.php | 67 ++++++++++ core/bootstrap/app.php | 12 +- core/routes/api.php | 10 ++ core/tests/Feature/ClientApiTest.php | 117 ++++++++++++++++++ .../tests/Feature/SourceCreateCommandTest.php | 29 +++++ 9 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 core/app/Console/Commands/SourceCreate.php create mode 100644 core/app/Http/Controllers/Api/EventsController.php create mode 100644 core/app/Http/Middleware/AuthenticateSource.php create mode 100644 core/app/Http/Middleware/RemoteUser.php create mode 100644 core/app/Http/Requests/UpsertEventsRequest.php create mode 100644 core/routes/api.php create mode 100644 core/tests/Feature/ClientApiTest.php create mode 100644 core/tests/Feature/SourceCreateCommandTest.php diff --git a/core/app/Console/Commands/SourceCreate.php b/core/app/Console/Commands/SourceCreate.php new file mode 100644 index 0000000..835f5e4 --- /dev/null +++ b/core/app/Console/Commands/SourceCreate.php @@ -0,0 +1,31 @@ +argument('name'); + if (Source::where('name', $name)->exists()) { + $this->error("Источник «{$name}» уже существует"); + + return self::FAILURE; + } + + $token = 'hado_'.bin2hex(random_bytes(32)); + Source::create(['name' => $name, 'token_hash' => hash('sha256', $token)]); + + $this->info("Источник «{$name}» создан. Токен показывается один раз, в базе только хэш."); + $this->line("Token: {$token}"); + + return self::SUCCESS; + } +} diff --git a/core/app/Http/Controllers/Api/EventsController.php b/core/app/Http/Controllers/Api/EventsController.php new file mode 100644 index 0000000..9972159 --- /dev/null +++ b/core/app/Http/Controllers/Api/EventsController.php @@ -0,0 +1,80 @@ +attributes->get('source'); + + return response()->json(['events' => $this->upserter->upsert($source, $request->items())]); + } + + public function show(Request $request): JsonResponse + { + $event = $this->lookup($request); + + return response()->json(self::present($event)); + } + + public function destroy(Request $request): Response + { + $event = $this->lookup($request); + $event->update(['state' => EventState::Withdrawn]); + + return response()->noContent(); + } + + private function lookup(Request $request): Event + { + $request->validate(['source_ref' => ['required', 'string'], 'topic' => ['sometimes', 'nullable', 'string']]); + /** @var Source $source */ + $source = $request->attributes->get('source'); + $event = $this->upserter->find($source, $request->query('source_ref'), (string) $request->query('topic', '')); + abort_if($event === null, 404, 'Событие не найдено'); + + return $event; + } + + /** @return array */ + public static function present(Event $event): array + { + return [ + 'id' => $event->id, + 'source_ref' => $event->source_ref, + 'topic' => $event->topic, + 'state' => $event->state->value, + 'due_at' => self::dueAt($event), + 'fire_at' => $event->fire_on->format('Y-m-d'), + 'after_due' => $event->after_due->value, + 'payload' => $event->payload, + 'done_at' => $event->done_at?->toIso8601String(), + ]; + } + + private static function dueAt(Event $event): string + { + if ($event->due_mode === DueMode::Fixed) { + return $event->due_instant->toIso8601String(); + } + $date = $event->due_date->format('Y-m-d'); + + return $event->due_time === DueResolver::WHOLE_DAY ? $date : $date.'T'.substr($event->due_time, 0, 5); + } +} diff --git a/core/app/Http/Middleware/AuthenticateSource.php b/core/app/Http/Middleware/AuthenticateSource.php new file mode 100644 index 0000000..4e39c0c --- /dev/null +++ b/core/app/Http/Middleware/AuthenticateSource.php @@ -0,0 +1,28 @@ +bearerToken(); + if ($token === null || $token === '') { + return response()->json(['message' => 'Нужен Bearer-токен источника'], 401); + } + + $source = Source::where('token_hash', hash('sha256', $token))->first(); + if ($source === null) { + return response()->json(['message' => 'Неизвестный токен источника'], 401); + } + + $request->attributes->set('source', $source); + + return $next($request); + } +} diff --git a/core/app/Http/Middleware/RemoteUser.php b/core/app/Http/Middleware/RemoteUser.php new file mode 100644 index 0000000..d9d0595 --- /dev/null +++ b/core/app/Http/Middleware/RemoteUser.php @@ -0,0 +1,15 @@ + ['required', 'array', 'min:1', 'max:500'], + 'events.*.user' => ['required', 'string', 'max:255'], + 'events.*.source_ref' => ['required', 'string', 'max:255'], + 'events.*.topic' => ['sometimes', 'nullable', 'string', 'max:255'], + 'events.*.due_at' => ['required', 'string'], + 'events.*.fire_at' => ['required', 'date_format:Y-m-d'], + 'events.*.after_due' => ['required', 'in:keep,expire'], + 'events.*.payload' => ['required', 'array'], + 'events.*.payload.title' => ['required', 'string'], + 'events.*.payload.subtitle' => ['sometimes', 'nullable', 'string'], + 'events.*.payload.deep_link' => ['sometimes', 'nullable', 'string'], + 'events.*.payload.done_label' => ['sometimes', 'nullable', 'string', 'max:64'], + ]; + } + + public function after(): array + { + return [function (Validator $v) { + foreach ((array) $this->input('events', []) as $i => $item) { + if (! is_array($item) || ! isset($item['due_at'], $item['fire_at']) || ! is_string($item['due_at'])) { + continue; + } + try { + $due = DueAtParser::parse($item['due_at']); + } catch (InvalidArgumentException $e) { + $v->errors()->add("events.$i.due_at", $e->getMessage()); + + continue; + } + // Проверка fire_at <= день срока — по поясу по умолчанию; для fixed это + // приближение, точный день пользователя известен только при upsert. + if ($v->errors()->missing("events.$i.fire_at") && $item['fire_at'] > $due->dayFor(config('hado.default_tz'))) { + $v->errors()->add("events.$i.fire_at", 'fire_at не может быть позже дня срока'); + } + } + }]; + } + + /** @return list> */ + public function items(): array + { + return array_map(function (array $item) { + $item['topic'] = (string) ($item['topic'] ?? ''); + + return $item; + }, $this->validated()['events']); + } +} diff --git a/core/bootstrap/app.php b/core/bootstrap/app.php index 1085719..7e6a507 100644 --- a/core/bootstrap/app.php +++ b/core/bootstrap/app.php @@ -1,5 +1,7 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->trustProxies(at: '*'); + $middleware->alias([ + 'auth.source' => AuthenticateSource::class, + 'remote.user' => RemoteUser::class, + ]); + $middleware->validateCsrfTokens(except: ['a/*', 'hooks/*']); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + fn (Request $request) => $request->expectsJson() || $request->is('api/*') || $request->is('a/*') || $request->is('hooks/*'), ); })->create(); diff --git a/core/routes/api.php b/core/routes/api.php new file mode 100644 index 0000000..93f8683 --- /dev/null +++ b/core/routes/api.php @@ -0,0 +1,10 @@ +group(function () { + Route::put('/events', [EventsController::class, 'upsert']); + Route::get('/events', [EventsController::class, 'show']); + Route::delete('/events', [EventsController::class, 'destroy']); +}); diff --git a/core/tests/Feature/ClientApiTest.php b/core/tests/Feature/ClientApiTest.php new file mode 100644 index 0000000..d8b2ea8 --- /dev/null +++ b/core/tests/Feature/ClientApiTest.php @@ -0,0 +1,117 @@ + 'docs', 'token_hash' => hash('sha256', self::TOKEN)]); + } + + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + parent::tearDown(); + } + + private function item(array $over = []): array + { + return array_replace([ + 'user' => 'nikita', 'source_ref' => 'person:42', 'topic' => 'birthday:2026', + 'due_at' => '2026-08-14', 'fire_at' => '2026-08-14', 'after_due' => 'expire', + 'payload' => ['title' => 'Сегодня ДР — Вася', 'deep_link' => '/people/42'], + ], $over); + } + + private function auth(): array + { + return ['Authorization' => 'Bearer '.self::TOKEN]; + } + + public function test_rejects_missing_or_wrong_token(): void + { + $this->putJson('/api/events', ['events' => [$this->item()]])->assertStatus(401); + $this->putJson('/api/events', ['events' => [$this->item()]], ['Authorization' => 'Bearer nope'])->assertStatus(401); + } + + public function test_upserts_batch_and_returns_ids_and_states(): void + { + $this->putJson('/api/events', ['events' => [$this->item()]], $this->auth()) + ->assertOk() + ->assertJsonPath('events.0.source_ref', 'person:42') + ->assertJsonPath('events.0.topic', 'birthday:2026') + ->assertJsonPath('events.0.state', 'expired'); + + $this->assertSame(1, Event::count()); + } + + public function test_validation_rejects_whole_batch_with_index(): void + { + $bad = $this->item(['due_at' => 'tomorrow']); + $this->putJson('/api/events', ['events' => [$this->item(), $bad]], $this->auth()) + ->assertStatus(422) + ->assertJsonValidationErrors(['events.1.due_at']); + + $this->assertSame(0, Event::count(), 'ничего не применилось'); + } + + public function test_validation_rules(): void + { + $this->putJson('/api/events', ['events' => [$this->item(['fire_at' => '2026-08-15'])]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.fire_at']); + $this->putJson('/api/events', ['events' => [$this->item(['after_due' => 'maybe'])]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.after_due']); + $this->putJson('/api/events', ['events' => [$this->item(['payload' => ['subtitle' => 'x']])]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.payload.title']); + $this->putJson('/api/events', ['events' => [$this->item(['user' => ''])]], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events.0.user']); + $this->putJson('/api/events', ['events' => array_fill(0, 501, $this->item())], $this->auth()) + ->assertStatus(422)->assertJsonValidationErrors(['events']); + } + + public function test_topic_defaults_to_empty_string(): void + { + $item = $this->item(); + unset($item['topic']); + $this->putJson('/api/events', ['events' => [$item]], $this->auth())->assertOk()->assertJsonPath('events.0.topic', ''); + } + + public function test_get_and_delete_by_identity(): void + { + $this->putJson('/api/events', ['events' => [$this->item(['due_at' => '2026-09-30', 'fire_at' => '2026-08-31', 'after_due' => 'keep'])]], $this->auth()); + + $this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', $this->auth()) + ->assertOk() + ->assertJsonPath('state', 'preparing') + ->assertJsonPath('due_at', '2026-09-30') + ->assertJsonPath('topic', 'birthday:2026'); + + $this->deleteJson('/api/events?source_ref=person:42&topic=birthday:2026', [], $this->auth())->assertNoContent(); + $this->assertSame(EventState::Withdrawn, Event::sole()->state); + + $this->getJson('/api/events?source_ref=person:42&topic=nope', $this->auth())->assertNotFound(); + $this->deleteJson('/api/events?source_ref=person:42&topic=nope', [], $this->auth())->assertNotFound(); + } + + public function test_source_is_isolated_by_token(): void + { + Source::create(['name' => 'other', 'token_hash' => hash('sha256', 'other-token')]); + $this->putJson('/api/events', ['events' => [$this->item()]], $this->auth()); + + $this->getJson('/api/events?source_ref=person:42&topic=birthday:2026', ['Authorization' => 'Bearer other-token'])->assertNotFound(); + } +} diff --git a/core/tests/Feature/SourceCreateCommandTest.php b/core/tests/Feature/SourceCreateCommandTest.php new file mode 100644 index 0000000..7ae3b45 --- /dev/null +++ b/core/tests/Feature/SourceCreateCommandTest.php @@ -0,0 +1,29 @@ +artisan('hado:source:create', ['name' => 'docs']) + ->expectsOutputToContain('Token: hado_') + ->assertSuccessful(); + + $source = Source::where('name', 'docs')->sole(); + $this->assertSame(64, strlen($source->token_hash)); + } + + public function test_refuses_duplicate_name(): void + { + Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + + $this->artisan('hado:source:create', ['name' => 'docs'])->assertFailed(); + } +}