feat: phase 2 api parity endpoints for extension
This commit is contained in:
@@ -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=
|
||||
|
||||
226
app/app/Http/Controllers/Api/V1/EmojiApiController.php
Normal file
226
app/app/Http/Controllers/Api/V1/EmojiApiController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
app/app/Http/Controllers/Api/V1/LicenseController.php
Normal file
76
app/app/Http/Controllers/Api/V1/LicenseController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
16
app/config/dewemoji.php
Normal file
16
app/config/dewemoji.php
Normal 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
20
app/routes/api.php
Normal 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']);
|
||||
});
|
||||
|
||||
57
app/tests/Feature/ApiV1EndpointsTest.php
Normal file
57
app/tests/Feature/ApiV1EndpointsTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
39
app/tests/Fixtures/emojis.fixture.json
Normal file
39
app/tests/Fixtures/emojis.fixture.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user