From 8816522dddd66a9cd10086c8a338a79a6f5ac7b5 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Tue, 3 Feb 2026 22:06:08 +0700 Subject: [PATCH] feat: phase 2 api parity endpoints for extension --- README.md | 1 + app/.env.example | 6 + .../Controllers/Api/V1/EmojiApiController.php | 226 ++++++++++++++++++ .../Controllers/Api/V1/LicenseController.php | 76 ++++++ app/bootstrap/app.php | 2 + app/config/dewemoji.php | 16 ++ app/routes/api.php | 20 ++ app/tests/Feature/ApiV1EndpointsTest.php | 57 +++++ app/tests/Fixtures/emojis.fixture.json | 39 +++ phase-2-api.md | 43 ++++ rebuild-progress.md | 16 +- 11 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 app/app/Http/Controllers/Api/V1/EmojiApiController.php create mode 100644 app/app/Http/Controllers/Api/V1/LicenseController.php create mode 100644 app/config/dewemoji.php create mode 100644 app/routes/api.php create mode 100644 app/tests/Feature/ApiV1EndpointsTest.php create mode 100644 app/tests/Fixtures/emojis.fixture.json create mode 100644 phase-2-api.md diff --git a/README.md b/README.md index c283d48..a22ee00 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This documentation now uses the corrected legacy/source folders: 3. `legacy-credentials-and-config.md` 4. `rebuild-progress.md` 5. `phase-1-foundation.md` +6. `phase-2-api.md` ## Note diff --git a/app/.env.example b/app/.env.example index c0660ea..2f4d980 100644 --- a/app/.env.example +++ b/app/.env.example @@ -63,3 +63,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +DEWEMOJI_DATA_PATH= +DEWEMOJI_DEFAULT_LIMIT=20 +DEWEMOJI_MAX_LIMIT=50 +DEWEMOJI_LICENSE_ACCEPT_ALL=true +DEWEMOJI_PRO_KEYS= diff --git a/app/app/Http/Controllers/Api/V1/EmojiApiController.php b/app/app/Http/Controllers/Api/V1/EmojiApiController.php new file mode 100644 index 0000000..138f01a --- /dev/null +++ b/app/app/Http/Controllers/Api/V1/EmojiApiController.php @@ -0,0 +1,226 @@ +|null */ + private static ?array $dataset = null; + + public function categories(): JsonResponse + { + $tier = $this->detectTier(request()); + try { + $data = $this->loadData(); + } catch (RuntimeException $e) { + return $this->jsonWithTier([ + 'error' => 'data_load_failed', + 'message' => $e->getMessage(), + ], $tier, 500); + } + $items = $data['emojis'] ?? []; + + $map = []; + foreach ($items as $item) { + $category = (string) ($item['category'] ?? ''); + $subcategory = (string) ($item['subcategory'] ?? ''); + if ($category === '' || $subcategory === '') { + continue; + } + + $map[$category] ??= []; + $map[$category][$subcategory] = true; + } + + $out = []; + foreach ($map as $category => $subcategories) { + $out[$category] = array_keys($subcategories); + sort($out[$category], SORT_NATURAL | SORT_FLAG_CASE); + } + ksort($out, SORT_NATURAL | SORT_FLAG_CASE); + + return $this->jsonWithTier($out, $tier); + } + + public function emojis(Request $request): JsonResponse + { + $tier = $this->detectTier($request); + try { + $data = $this->loadData(); + } catch (RuntimeException $e) { + return $this->jsonWithTier([ + 'error' => 'data_load_failed', + 'message' => $e->getMessage(), + ], $tier, 500); + } + $items = $data['emojis'] ?? []; + + $q = trim((string) ($request->query('q', $request->query('query', '')))); + $category = trim((string) $request->query('category', '')); + $subcategory = trim((string) $request->query('subcategory', '')); + $page = max((int) $request->query('page', 1), 1); + + $defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1); + $maxLimit = max((int) config('dewemoji.pagination.max_limit', 50), 1); + $limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit); + + $filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subcategory): bool { + if ($category !== '' && strcasecmp((string) ($item['category'] ?? ''), $category) !== 0) { + return false; + } + + if ($subcategory !== '' && strcasecmp((string) ($item['subcategory'] ?? ''), $subcategory) !== 0) { + return false; + } + + if ($q === '') { + return true; + } + + $haystack = strtolower(implode(' ', [ + (string) ($item['emoji'] ?? ''), + (string) ($item['name'] ?? ''), + (string) ($item['slug'] ?? ''), + (string) ($item['category'] ?? ''), + (string) ($item['subcategory'] ?? ''), + implode(' ', $item['keywords_en'] ?? []), + implode(' ', $item['keywords_id'] ?? []), + implode(' ', $item['aliases'] ?? []), + implode(' ', $item['shortcodes'] ?? []), + implode(' ', $item['alt_shortcodes'] ?? []), + implode(' ', $item['intent_tags'] ?? []), + ])); + + return str_contains($haystack, strtolower($q)); + })); + + $total = count($filtered); + $offset = ($page - 1) * $limit; + $pageItems = array_slice($filtered, $offset, $limit); + + $outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems); + + return $this->jsonWithTier([ + 'items' => $outItems, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + ], $tier); + } + + private function detectTier(Request $request): string + { + $key = trim((string) $request->bearerToken()); + if ($key === '') { + $key = trim((string) $request->header('X-License-Key', '')); + } + if ($key === '') { + return self::TIER_FREE; + } + + if ((bool) config('dewemoji.license.accept_all', false)) { + return self::TIER_PRO; + } + + $validKeys = config('dewemoji.license.pro_keys', []); + if (is_array($validKeys) && in_array($key, $validKeys, true)) { + return self::TIER_PRO; + } + + return self::TIER_FREE; + } + + /** + * @return array + */ + private function loadData(): array + { + if (self::$dataset !== null) { + return self::$dataset; + } + + $path = (string) config('dewemoji.data_path'); + if (!is_file($path)) { + throw new RuntimeException('Emoji dataset file was not found at: '.$path); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new RuntimeException('Emoji dataset file could not be read.'); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new RuntimeException('Emoji dataset JSON is invalid.'); + } + + self::$dataset = $decoded; + + return self::$dataset; + } + + /** + * @param array $item + * @return array + */ + private function transformItem(array $item, string $tier): array + { + $out = [ + 'emoji' => (string) ($item['emoji'] ?? ''), + 'name' => (string) ($item['name'] ?? ''), + 'slug' => (string) ($item['slug'] ?? ''), + 'category' => (string) ($item['category'] ?? ''), + 'subcategory' => (string) ($item['subcategory'] ?? ''), + 'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false), + 'summary' => $this->summary((string) ($item['description'] ?? ''), 150), + ]; + + if ($tier === self::TIER_PRO) { + $out += [ + 'unified' => (string) ($item['unified'] ?? ''), + 'codepoints' => $item['codepoints'] ?? [], + 'shortcodes' => $item['shortcodes'] ?? [], + 'aliases' => $item['aliases'] ?? [], + 'keywords_en' => $item['keywords_en'] ?? [], + 'keywords_id' => $item['keywords_id'] ?? [], + 'related' => $item['related'] ?? [], + 'intent_tags' => $item['intent_tags'] ?? [], + 'description' => (string) ($item['description'] ?? ''), + ]; + } + + return $out; + } + + private function summary(string $text, int $max): string + { + $text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? ''); + if (mb_strlen($text) <= $max) { + return $text; + } + + return rtrim(mb_substr($text, 0, $max - 1), " ,.;:-").'…'; + } + + /** + * @param array $payload + */ + private function jsonWithTier(array $payload, string $tier, int $status = 200): JsonResponse + { + return response() + ->json($payload, $status, [ + 'X-Dewemoji-Tier' => $tier, + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', + ]); + } +} diff --git a/app/app/Http/Controllers/Api/V1/LicenseController.php b/app/app/Http/Controllers/Api/V1/LicenseController.php new file mode 100644 index 0000000..997459a --- /dev/null +++ b/app/app/Http/Controllers/Api/V1/LicenseController.php @@ -0,0 +1,76 @@ +input('key', '')); + $accountId = trim((string) $request->input('account_id', '')); + $version = trim((string) $request->input('version', '')); + + if ($key === '') { + return $this->response([ + 'ok' => false, + 'error' => 'missing_key', + ]); + } + + if ($accountId === '') { + return $this->response([ + 'ok' => false, + 'error' => 'missing_account_id', + ]); + } + + if ($version === '') { + return $this->response([ + 'ok' => false, + 'error' => 'missing_version', + ]); + } + + $valid = $this->isValidKey($key); + + return $this->response([ + 'ok' => $valid, + 'tier' => $valid ? 'pro' : 'free', + 'error' => $valid ? null : 'invalid_license', + ]); + } + + private function isValidKey(string $key): bool + { + if ((bool) config('dewemoji.license.accept_all', false)) { + return true; + } + + $validKeys = config('dewemoji.license.pro_keys', []); + if (!is_array($validKeys)) { + return false; + } + + return in_array($key, $validKeys, true); + } + + /** + * @param array $payload + */ + private function response(array $payload, int $status = 200): JsonResponse + { + $tier = ($payload['ok'] ?? false) ? 'pro' : 'free'; + + return response()->json($payload, $status, [ + 'X-Dewemoji-Tier' => $tier, + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', + ]); + } +} + diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index c183276..ecde68a 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -7,6 +7,8 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + apiPrefix: '', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/app/config/dewemoji.php b/app/config/dewemoji.php new file mode 100644 index 0000000..1ca15a2 --- /dev/null +++ b/app/config/dewemoji.php @@ -0,0 +1,16 @@ + env('DEWEMOJI_DATA_PATH', base_path('../../dewemoji-api/data/emojis.json')), + + 'pagination' => [ + 'default_limit' => (int) env('DEWEMOJI_DEFAULT_LIMIT', 20), + 'max_limit' => (int) env('DEWEMOJI_MAX_LIMIT', 50), + ], + + 'license' => [ + 'accept_all' => filter_var(env('DEWEMOJI_LICENSE_ACCEPT_ALL', false), FILTER_VALIDATE_BOOL), + 'pro_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_PRO_KEYS', ''))))), + ], +]; + diff --git a/app/routes/api.php b/app/routes/api.php new file mode 100644 index 0000000..6b8e8de --- /dev/null +++ b/app/routes/api.php @@ -0,0 +1,20 @@ + '*', + 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', + ]); +})->where('any', '.*'); + +Route::prefix('v1')->group(function () { + Route::get('/categories', [EmojiApiController::class, 'categories']); + Route::get('/emojis', [EmojiApiController::class, 'emojis']); + Route::post('/license/verify', [LicenseController::class, 'verify']); +}); + diff --git a/app/tests/Feature/ApiV1EndpointsTest.php b/app/tests/Feature/ApiV1EndpointsTest.php new file mode 100644 index 0000000..c531836 --- /dev/null +++ b/app/tests/Feature/ApiV1EndpointsTest.php @@ -0,0 +1,57 @@ +set('dewemoji.data_path', base_path('tests/Fixtures/emojis.fixture.json')); + } + + public function test_categories_endpoint_returns_category_map(): void + { + $response = $this->getJson('/v1/categories'); + + $response + ->assertOk() + ->assertHeader('X-Dewemoji-Tier', 'free') + ->assertJsonPath('Smileys & Emotion.0', 'face-smiling') + ->assertJsonPath('People & Body.0', 'hand-fingers-closed'); + } + + public function test_emojis_endpoint_supports_both_q_and_query_params(): void + { + $byQ = $this->getJson('/v1/emojis?q=grinning'); + $byQuery = $this->getJson('/v1/emojis?query=grinning'); + + $byQ->assertOk()->assertJsonPath('total', 1); + $byQuery->assertOk()->assertJsonPath('total', 1); + $byQ->assertJsonPath('items.0.slug', 'grinning-face'); + $byQuery->assertJsonPath('items.0.slug', 'grinning-face'); + } + + public function test_license_verify_returns_ok_when_accept_all_is_enabled(): void + { + config()->set('dewemoji.license.accept_all', true); + + $response = $this->postJson('/v1/license/verify', [ + 'key' => 'dummy-key', + 'account_id' => 'acct_123', + 'version' => '1.0.0', + ]); + + $response + ->assertOk() + ->assertHeader('X-Dewemoji-Tier', 'pro') + ->assertJson([ + 'ok' => true, + 'tier' => 'pro', + ]); + } +} + diff --git a/app/tests/Fixtures/emojis.fixture.json b/app/tests/Fixtures/emojis.fixture.json new file mode 100644 index 0000000..fef4d3b --- /dev/null +++ b/app/tests/Fixtures/emojis.fixture.json @@ -0,0 +1,39 @@ +{ + "emojis": [ + { + "emoji": "😀", + "name": "grinning face", + "slug": "grinning-face", + "category": "Smileys & Emotion", + "subcategory": "face-smiling", + "supports_skin_tone": false, + "description": "A happy smiling face.", + "unified": "U+1F600", + "codepoints": ["1F600"], + "shortcodes": [":grinning_face:"], + "aliases": ["grin face"], + "keywords_en": ["happy", "smile"], + "keywords_id": ["senang", "senyum"], + "related": ["😃"], + "intent_tags": ["happiness"] + }, + { + "emoji": "👍", + "name": "thumbs up", + "slug": "thumbs-up", + "category": "People & Body", + "subcategory": "hand-fingers-closed", + "supports_skin_tone": true, + "description": "A thumbs up gesture.", + "unified": "U+1F44D", + "codepoints": ["1F44D"], + "shortcodes": [":thumbsup:"], + "aliases": ["like"], + "keywords_en": ["ok", "good"], + "keywords_id": ["bagus"], + "related": ["👎"], + "intent_tags": ["approval"] + } + ] +} + diff --git a/phase-2-api.md b/phase-2-api.md new file mode 100644 index 0000000..75ea1d0 --- /dev/null +++ b/phase-2-api.md @@ -0,0 +1,43 @@ +# Phase 2 API Delivery + +## Implemented endpoints (Laravel app) + +Base path: `app/` project + +- `GET /v1/categories` +- `GET /v1/emojis` +- `POST /v1/license/verify` +- `OPTIONS /v1/{any}` (CORS preflight) + +## Compatibility behavior + +- `/v1/*` routes are exposed without `/api` prefix (extension-compatible). +- `GET /v1/emojis` accepts both `q` and `query`. +- Responses include `X-Dewemoji-Tier` header. + +## Current license mode + +Environment-driven stub: + +- `DEWEMOJI_LICENSE_ACCEPT_ALL=true` => any key is accepted as Pro. +- `DEWEMOJI_PRO_KEYS=key1,key2` => explicit key whitelist mode. + +## Data source + +Configured via: + +- `DEWEMOJI_DATA_PATH` (defaults to `../../dewemoji-api/data/emojis.json` from `app/`). + +## Test coverage + +Added feature tests: + +- `app/tests/Feature/ApiV1EndpointsTest.php` +- fixture: `app/tests/Fixtures/emojis.fixture.json` + +Run: + +```bash +cd app +php artisan test --filter=ApiV1EndpointsTest +``` diff --git a/rebuild-progress.md b/rebuild-progress.md index 4f80976..005700c 100644 --- a/rebuild-progress.md +++ b/rebuild-progress.md @@ -27,11 +27,11 @@ - [x] Decide canonical data source: start from `dewemoji-api/data/emojis.json`. ### Phase 2 - API parity (extension first) -- [ ] Implement `GET /v1/emojis`. -- [ ] Implement `GET /v1/categories`. -- [ ] Implement `POST /v1/license/verify`. -- [ ] Preserve response/header compatibility (`X-Dewemoji-Tier`). -- [ ] Support both `q` and `query` inputs. +- [x] Implement `GET /v1/emojis`. +- [x] Implement `GET /v1/categories`. +- [x] Implement `POST /v1/license/verify`. +- [x] Preserve response/header compatibility (`X-Dewemoji-Tier`). +- [x] Support both `q` and `query` inputs. ### Phase 3 - Website rebuild - [ ] Build website pages in new app (index, emoji detail, api docs, legal pages). @@ -51,3 +51,9 @@ - Backend tier validation stubs in legacy PHP. - Contract differences between current `/api/*` and extension `/v1/*`. - Website source currently split (`dewemoji-api` active pages vs `dewemoji-site` empty scaffold). + +## Implementation notes (Phase 2) + +- Routes are now available at `/v1/*` (no `/api` prefix) for extension compatibility. +- License verification is currently environment-driven (`DEWEMOJI_LICENSE_ACCEPT_ALL` / `DEWEMOJI_PRO_KEYS`) as a safe stub before real provider integration. +- Test coverage added for `v1` endpoints in `app/tests/Feature/ApiV1EndpointsTest.php`.