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

@@ -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',
]);
}
}