feat: phase 2 api parity endpoints for extension

This commit is contained in:
Dwindi Ramadhana
2026-02-03 22:06:08 +07:00
parent dcec38ba94
commit 8816522ddd
11 changed files with 497 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ This documentation now uses the corrected legacy/source folders:
3. `legacy-credentials-and-config.md` 3. `legacy-credentials-and-config.md`
4. `rebuild-progress.md` 4. `rebuild-progress.md`
5. `phase-1-foundation.md` 5. `phase-1-foundation.md`
6. `phase-2-api.md`
## Note ## Note

View File

@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
DEWEMOJI_DATA_PATH=
DEWEMOJI_DEFAULT_LIMIT=20
DEWEMOJI_MAX_LIMIT=50
DEWEMOJI_LICENSE_ACCEPT_ALL=true
DEWEMOJI_PRO_KEYS=

View File

@@ -0,0 +1,226 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class EmojiApiController extends Controller
{
private const TIER_FREE = 'free';
private const TIER_PRO = 'pro';
/** @var array<string,mixed>|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<string,mixed>
*/
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<string,mixed> $item
* @return array<string,mixed>
*/
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<string,mixed> $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',
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LicenseController extends Controller
{
public function verify(Request $request): JsonResponse
{
$key = trim((string) $request->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<string,mixed> $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',
]);
}
}

View File

@@ -7,6 +7,8 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
apiPrefix: '',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )

16
app/config/dewemoji.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
return [
'data_path' => 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', ''))))),
],
];

20
app/routes/api.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use App\Http\Controllers\Api\V1\EmojiApiController;
use App\Http\Controllers\Api\V1\LicenseController;
use Illuminate\Support\Facades\Route;
Route::options('/v1/{any}', function () {
return response('', 204, [
'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',
]);
})->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']);
});

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class ApiV1EndpointsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->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',
]);
}
}

View File

@@ -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"]
}
]
}

43
phase-2-api.md Normal file
View File

@@ -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
```

View File

@@ -27,11 +27,11 @@
- [x] Decide canonical data source: start from `dewemoji-api/data/emojis.json`. - [x] Decide canonical data source: start from `dewemoji-api/data/emojis.json`.
### Phase 2 - API parity (extension first) ### Phase 2 - API parity (extension first)
- [ ] Implement `GET /v1/emojis`. - [x] Implement `GET /v1/emojis`.
- [ ] Implement `GET /v1/categories`. - [x] Implement `GET /v1/categories`.
- [ ] Implement `POST /v1/license/verify`. - [x] Implement `POST /v1/license/verify`.
- [ ] Preserve response/header compatibility (`X-Dewemoji-Tier`). - [x] Preserve response/header compatibility (`X-Dewemoji-Tier`).
- [ ] Support both `q` and `query` inputs. - [x] Support both `q` and `query` inputs.
### Phase 3 - Website rebuild ### Phase 3 - Website rebuild
- [ ] Build website pages in new app (index, emoji detail, api docs, legal pages). - [ ] Build website pages in new app (index, emoji detail, api docs, legal pages).
@@ -51,3 +51,9 @@
- Backend tier validation stubs in legacy PHP. - Backend tier validation stubs in legacy PHP.
- Contract differences between current `/api/*` and extension `/v1/*`. - Contract differences between current `/api/*` and extension `/v1/*`.
- Website source currently split (`dewemoji-api` active pages vs `dewemoji-site` empty scaffold). - 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`.