chore: split api responses from server, allowing reuse, prepare npm package

This commit is contained in:
Vjacheslav Trushkin 2024-01-17 09:45:43 +02:00
parent 2181b8022c
commit ceb3fc4394
16 changed files with 245 additions and 173 deletions

18
.npmignore Normal file
View File

@ -0,0 +1,18 @@
/.idea
/.vscode
.DS_Store
/.env
/.editorconfig
/.prettierrc
*.map
/docker.sh
/Dockerfile
/tsconfig*.*
/vitest.config.*
/.github
/src
/node_modules
/cache
/tmp
/icons
/tests

View File

@ -6,6 +6,12 @@ This repository contains Iconify API script. It is a HTTP server, written in Nod
- Generates SVG, which you can link to in HTML or stylesheet.
- Provides search engine for hosted icons, which can be used by icon pickers.
## NPM Package
This package is also available at NPM, allowing using API code in custom wrappers.
NPM package contains only compiled files, to build custom Docker image you need to use source files from Git repository, not NPM package.
## Docker
To build a Docker image, run `./docker.sh`.

View File

@ -3,8 +3,11 @@
"description": "Iconify API",
"author": "Vjacheslav Trushkin",
"license": "MIT",
"private": true,
"version": "3.0.2",
"version": "3.1.0-beta.1",
"publishConfig": {
"access": "public",
"tag": "next"
},
"type": "module",
"bugs": "https://github.com/iconify/api/issues",
"homepage": "https://github.com/iconify/api",

View File

@ -125,8 +125,9 @@ export function updateIconSets(): number {
/**
* Trigger update
*/
export function triggerIconSetsUpdate() {
export function triggerIconSetsUpdate(done?: (success?: boolean) => void) {
if (!importers) {
done?.();
return;
}
console.log('Checking for updates...');
@ -147,6 +148,10 @@ export function triggerIconSetsUpdate() {
.then((updated) => {
console.log(updated ? 'Update complete' : 'Nothing to update');
updateIconSets();
done?.(true);
})
.catch(console.error);
.catch((err) => {
console.error(err);
done?.(false);
});
}

View File

@ -0,0 +1,45 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { checkJSONPQuery, sendJSONResponse } from './json.js';
import { createIconsDataResponse } from '../responses/icons.js';
type CallbackResult = object | number;
/**
* Handle icons data API response
*/
export function handleIconsDataResponse(
prefix: string,
wrapJS: boolean,
query: FastifyRequest['query'],
res: FastifyReply
) {
const q = (query || {}) as Record<string, string>;
// Check for JSONP
const wrap = checkJSONPQuery(q, wrapJS, 'SimpleSVG._loaderCallback');
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
// Function to send response
const respond = (result: CallbackResult) => {
if (typeof result === 'number') {
res.send(result);
} else {
sendJSONResponse(result, q, wrap, res);
}
};
// Get result
const result = createIconsDataResponse(prefix, q);
if (result instanceof Promise) {
result.then(respond).catch((err) => {
console.error(err);
respond(500);
});
} else {
respond(result);
}
}

43
src/http/helpers/send.ts Normal file
View File

@ -0,0 +1,43 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { checkJSONPQuery, sendJSONResponse } from './json.js';
type CallbackResult = object | number;
/**
* Handle JSON API response generated by a callback
*/
export function handleJSONResponse(
req: FastifyRequest,
res: FastifyReply,
callback: (query: Record<string, string>) => CallbackResult | Promise<CallbackResult>
) {
const q = (req.query || {}) as Record<string, string>;
// Check for JSONP
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
// Function to send response
const respond = (result: CallbackResult) => {
if (typeof result === 'number') {
res.send(result);
} else {
sendJSONResponse(result, q, wrap, res);
}
};
// Get result
const result = callback(q);
if (result instanceof Promise) {
result.then(respond).catch((err) => {
console.error(err);
respond(500);
});
} else {
respond(result);
}
}

View File

@ -3,17 +3,18 @@ import fastifyFormBody from '@fastify/formbody';
import { appConfig, httpHeaders } from '../config/app.js';
import { runWhenLoaded } from '../data/loading.js';
import { iconNameRoutePartialRegEx, iconNameRouteRegEx, splitIconName } from '../misc/name.js';
import { generateAPIv1IconsListResponse } from './responses/collection-v1.js';
import { generateAPIv2CollectionResponse } from './responses/collection-v2.js';
import { generateCollectionsListResponse } from './responses/collections.js';
import { generateIconsDataResponse } from './responses/icons.js';
import { generateKeywordsResponse } from './responses/keywords.js';
import { generateLastModifiedResponse } from './responses/modified.js';
import { generateAPIv2SearchResponse } from './responses/search.js';
import { createAPIv1IconsListResponse } from './responses/collection-v1.js';
import { createAPIv2CollectionResponse } from './responses/collection-v2.js';
import { createCollectionsListResponse } from './responses/collections.js';
import { handleIconsDataResponse } from './helpers/send-icons.js';
import { createKeywordsResponse } from './responses/keywords.js';
import { createLastModifiedResponse } from './responses/modified.js';
import { createAPIv2SearchResponse } from './responses/search.js';
import { generateSVGResponse } from './responses/svg.js';
import { generateUpdateResponse } from './responses/update.js';
import { initVersionResponse, versionResponse } from './responses/version.js';
import { generateIconsStyleResponse } from './responses/css.js';
import { handleJSONResponse } from './helpers/send.js';
/**
* Start HTTP server
@ -88,12 +89,12 @@ export async function startHTTPServer() {
// Icons data: /prefix/icons.json, /prefix.json
server.get('/:prefix(' + iconNameRoutePartialRegEx + ')/icons.json', (req, res) => {
runWhenLoaded(() => {
generateIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
handleIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
});
});
server.get('/:prefix(' + iconNameRoutePartialRegEx + ').json', (req, res) => {
runWhenLoaded(() => {
generateIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
handleIconsDataResponse((req.params as PrefixParams).prefix, false, req.query, res);
});
});
@ -107,19 +108,19 @@ export async function startHTTPServer() {
// Icons data: /prefix/icons.js, /prefix.js
server.get('/:prefix(' + iconNameRoutePartialRegEx + ')/icons.js', (req, res) => {
runWhenLoaded(() => {
generateIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
handleIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
});
});
server.get('/:prefix(' + iconNameRoutePartialRegEx + ').js', (req, res) => {
runWhenLoaded(() => {
generateIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
handleIconsDataResponse((req.params as PrefixParams).prefix, true, req.query, res);
});
});
// Last modification time
server.get('/last-modified', (req, res) => {
runWhenLoaded(() => {
generateLastModifiedResponse(req.query, res);
handleJSONResponse(req, res, createLastModifiedResponse);
});
});
@ -127,26 +128,26 @@ export async function startHTTPServer() {
// Icon sets list
server.get('/collections', (req, res) => {
runWhenLoaded(() => {
generateCollectionsListResponse(req.query, res);
handleJSONResponse(req, res, createCollectionsListResponse);
});
});
// Icons list, API v2
server.get('/collection', (req, res) => {
runWhenLoaded(() => {
generateAPIv2CollectionResponse(req.query, res);
handleJSONResponse(req, res, createAPIv2CollectionResponse);
});
});
// Icons list, API v1
server.get('/list-icons', (req, res) => {
runWhenLoaded(() => {
generateAPIv1IconsListResponse(req.query, res, false);
handleJSONResponse(req, res, (q) => createAPIv1IconsListResponse(q, false));
});
});
server.get('/list-icons-categorized', (req, res) => {
runWhenLoaded(() => {
generateAPIv1IconsListResponse(req.query, res, true);
handleJSONResponse(req, res, (q) => createAPIv1IconsListResponse(q, true));
});
});
@ -154,14 +155,14 @@ export async function startHTTPServer() {
// Search, currently version 2
server.get('/search', (req, res) => {
runWhenLoaded(() => {
generateAPIv2SearchResponse(req.query, res);
handleJSONResponse(req, res, createAPIv2SearchResponse);
});
});
// Keywords
server.get('/keywords', (req, res) => {
runWhenLoaded(() => {
generateKeywordsResponse(req.query, res);
handleJSONResponse(req, res, createKeywordsResponse);
});
});
}

View File

@ -1,4 +1,3 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { IconSetAPIv2IconsList } from '../../types/icon-set/extra.js';
import type { StoredIconSet } from '../../types/icon-set/storage.js';
@ -7,11 +6,13 @@ import type {
APIv1ListIconsCategorisedResponse,
APIv1ListIconsResponse,
} from '../../types/server/v1.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
// Response results, depends on `categorised` option
type PossibleResults = APIv1ListIconsResponse | APIv1ListIconsCategorisedResponse;
/**
* Send API v2 response
* Create API v1 response
*
* This response ignores the following parameters:
* - `aliases` -> always enabled
@ -19,27 +20,15 @@ import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
*
* Those parameters are always requested anyway, so does not make sense to re-create data in case they are disabled
*/
export function generateAPIv1IconsListResponse(
query: FastifyRequest['query'],
res: FastifyReply,
export function createAPIv1IconsListResponse(
query: Record<string, string>,
categorised: boolean
) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
): PossibleResults | Record<string, PossibleResults> | number {
function parse(
prefix: string,
iconSet: StoredIconSet,
v2Cache: IconSetAPIv2IconsList
): APIv1ListIconsResponse | APIv1ListIconsCategorisedResponse {
const icons = iconSet.icons;
// Generate common data
const base: APIv1ListIconsBaseResponse = {
prefix,
@ -48,13 +37,13 @@ export function generateAPIv1IconsListResponse(
if (v2Cache.title) {
base.title = v2Cache.title;
}
if (q.info && v2Cache.info) {
if (query.info && v2Cache.info) {
base.info = v2Cache.info;
}
if (q.aliases && v2Cache.aliases) {
if (query.aliases && v2Cache.aliases) {
base.aliases = v2Cache.aliases;
}
if (q.chars && v2Cache.chars) {
if (query.chars && v2Cache.chars) {
base.chars = v2Cache.chars;
}
@ -81,22 +70,20 @@ export function generateAPIv1IconsListResponse(
return result;
}
if (q.prefix) {
const prefix = q.prefix;
if (query.prefix) {
const prefix = query.prefix;
const iconSet = iconSets[prefix]?.item;
if (!iconSet || !iconSet.apiV2IconsCache) {
res.send(404);
return;
return 404;
}
sendJSONResponse(parse(prefix, iconSet, iconSet.apiV2IconsCache), q, wrap, res);
return;
return parse(prefix, iconSet, iconSet.apiV2IconsCache);
}
if (q.prefixes) {
if (query.prefixes) {
const prefixes = filterPrefixesByPrefix(
getPrefixes(),
{
prefixes: q.prefixes,
prefixes: query.prefixes,
},
false
);
@ -125,20 +112,18 @@ export function generateAPIv1IconsListResponse(
if (!items.length) {
// Empty list
res.send(404);
return;
return 404;
}
// Get all items
const result = Object.create(null) as Record<string, ReturnType<typeof parse>>;
const result = Object.create(null) as Record<string, PossibleResults>;
for (let i = 0; i < items.length; i++) {
const item = items[i];
result[item.prefix] = parse(item.prefix, item.iconSet, item.v2Cache);
}
sendJSONResponse(result, q, wrap, res);
return;
return result;
}
// Invalid
res.send(400);
return 400;
}

View File

@ -1,7 +1,5 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { iconSets } from '../../data/icon-sets.js';
import type { APIv2CollectionResponse } from '../../types/server/v2.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
/**
* Send API v2 response
@ -12,29 +10,18 @@ import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
*
* Those parameters are always requested anyway, so does not make sense to re-create data in case they are disabled
*/
export function generateAPIv2CollectionResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
export function createAPIv2CollectionResponse(q: Record<string, string>): APIv2CollectionResponse | number {
// Get icon set
const prefix = q.prefix;
if (!prefix || !iconSets[prefix]) {
res.send(404);
return;
return 404;
}
const iconSet = iconSets[prefix].item;
const apiV2IconsCache = iconSet.apiV2IconsCache;
if (!apiV2IconsCache) {
// Disabled
res.send(404);
return;
return 404;
}
// Generate response
@ -52,5 +39,5 @@ export function generateAPIv2CollectionResponse(query: FastifyRequest['query'],
delete response.chars;
}
sendJSONResponse(response, q, wrap, res);
return response;
}

View File

@ -1,7 +1,5 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { APIv2CollectionsResponse } from '../../types/server/v2.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
/**
@ -12,15 +10,7 @@ import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
* Ignored parameters:
* - hidden (always enabled)
*/
export function generateCollectionsListResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
export function createCollectionsListResponse(q: Record<string, string>): APIv2CollectionsResponse {
// Filter prefixes
const prefixes = filterPrefixesByPrefix(getPrefixes('info'), q, false);
const response = Object.create(null) as APIv2CollectionsResponse;
@ -33,5 +23,5 @@ export function generateCollectionsListResponse(query: FastifyRequest['query'],
}
}
sendJSONResponse(response, q, wrap, res);
return response;
}

View File

@ -1,45 +1,59 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { IconifyJSON } from '@iconify/types';
import { getStoredIconsData } from '../../data/icon-set/utils/get-icons.js';
import { iconSets } from '../../data/icon-sets.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
/**
* Generate icons data
*/
export function generateIconsDataResponse(
export function createIconsDataResponse(
prefix: string,
wrapJS: boolean,
query: FastifyRequest['query'],
res: FastifyReply
) {
const q = (query || {}) as Record<string, string>;
q: Record<string, string>
): number | IconifyJSON | Promise<IconifyJSON | number> {
const names = q.icons?.split(',');
if (!names || !names.length) {
// Missing or invalid icons parameter
res.send(404);
return;
}
// Check for JSONP
const wrap = checkJSONPQuery(q, wrapJS, 'SimpleSVG._loaderCallback');
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
return 404;
}
// Get icon set
const iconSet = iconSets[prefix];
if (!iconSet) {
// No such icon set
res.send(404);
return;
return 404;
}
// Get icons
// Get icons, possibly sync
let syncData: IconifyJSON | undefined;
let resolveData: undefined | ((data: IconifyJSON) => void);
getStoredIconsData(iconSet.item, names, (data) => {
// Send data
sendJSONResponse(data, q, wrap, res);
if (resolveData) {
resolveData(data);
} else {
syncData = data;
}
});
if (syncData) {
return syncData;
}
return new Promise((resolve) => {
resolveData = resolve;
});
}
/**
* Awaitable version of createIconsDataResponse()
*/
export function createIconsDataResponseAsync(prefix: string, q: Record<string, string>): Promise<IconifyJSON | number> {
return new Promise((resolve, reject) => {
const result = createIconsDataResponse(prefix, q);
if (result instanceof Promise) {
result.then(resolve).catch(reject);
} else {
resolve(result);
}
});
}

View File

@ -1,27 +1,16 @@
import { matchIconName } from '@iconify/utils/lib/icon/name';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { searchIndex } from '../../data/search.js';
import { getPartialKeywords } from '../../data/search/partial.js';
import type { APIv3KeywordsQuery, APIv3KeywordsResponse } from '../../types/server/keywords.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
/**
* Generate icons data
* Find full keywords for partial keyword
*/
export function generateKeywordsResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
export function createKeywordsResponse(q: Record<string, string>): number | APIv3KeywordsResponse {
// Check if search data is available
const searchIndexData = searchIndex.data;
if (!searchIndexData) {
res.send(404);
return;
return 404;
}
const keywords = searchIndexData.keywords;
@ -32,15 +21,16 @@ export function generateKeywordsResponse(query: FastifyRequest['query'], res: Fa
let failed = false;
if (typeof q.prefix === 'string') {
// Keywords should start with prefix
test = q.prefix;
suffixes = false;
} else if (typeof q.keyword === 'string') {
// All keywords that contain keyword
test = q.keyword;
suffixes = true;
} else {
// Invalid query
res.send(400);
return;
return 400;
}
test = test.toLowerCase().trim();
@ -71,5 +61,5 @@ export function generateKeywordsResponse(query: FastifyRequest['query'], res: Fa
matches: failed || invalid ? [] : getPartialKeywords(test, suffixes, searchIndexData)?.slice(0) || [],
};
sendJSONResponse(response, q, wrap, res);
return response;
}

View File

@ -1,21 +1,11 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getPrefixes, iconSets } from '../../data/icon-sets.js';
import type { APIv3LastModifiedResponse } from '../../types/server/modified.js';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json.js';
import { filterPrefixesByPrefix } from '../helpers/prefixes.js';
/**
* Generate icons data
* Get last modified time for all icon sets
*/
export function generateLastModifiedResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
export function createLastModifiedResponse(q: Record<string, string>): number | APIv3LastModifiedResponse {
// Filter prefixes
const prefixes = filterPrefixesByPrefix(getPrefixes(), q, false);
@ -36,5 +26,5 @@ export function generateLastModifiedResponse(query: FastifyRequest['query'], res
}
}
sendJSONResponse(response, q, wrap, res);
return response;
}

View File

@ -14,28 +14,17 @@ const defaultSearchLimit = minSearchLimit * 2;
/**
* Send API v2 response
*/
export function generateAPIv2SearchResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;
const wrap = checkJSONPQuery(q);
if (!wrap) {
// Invalid JSONP callback
res.send(400);
return;
}
export function createAPIv2SearchResponse(q: Record<string, string>): number | APIv2SearchResponse {
// Check if search data is available
const searchIndexData = searchIndex.data;
if (!searchIndexData) {
res.send(404);
return;
return 404;
}
// Get query
const keyword = q.query;
if (!keyword) {
res.send(400);
return;
return 400;
}
// Convert to params
@ -49,16 +38,14 @@ export function generateAPIv2SearchResponse(query: FastifyRequest['query'], res:
if (v2Query.limit) {
const limit = parseInt(v2Query.limit);
if (!limit) {
res.send(400);
return;
return 400;
}
params.limit = Math.max(minSearchLimit, Math.min(limit, maxSearchLimit));
}
if (v2Query.min) {
const limit = parseInt(v2Query.min);
if (!limit) {
res.send(400);
return;
return 400;
}
params.limit = Math.max(minSearchLimit, Math.min(limit, maxSearchLimit));
params.softLimit = true;
@ -68,8 +55,7 @@ export function generateAPIv2SearchResponse(query: FastifyRequest['query'], res:
if (v2Query.start) {
start = parseInt(v2Query.start);
if (isNaN(start) || start < 0 || start >= params.limit) {
res.send(400);
return;
return 400;
}
}
@ -130,5 +116,5 @@ export function generateAPIv2SearchResponse(query: FastifyRequest['query'], res:
};
}
sendJSONResponse(response, q, wrap, res);
return response;
}

View File

@ -1,30 +1,19 @@
import { config } from 'dotenv';
import { getImporters } from './config/icon-sets.js';
import { iconSetsStorage } from './data/icon-set/store/storage.js';
import { setImporters, updateIconSets } from './data/icon-sets.js';
import { loaded } from './data/loading.js';
import { cleanupStorageCache } from './data/storage/startup.js';
import { startHTTPServer } from './http/index.js';
import { loadEnvConfig } from './misc/load-config.js';
import { initAPI } from './init.js';
(async () => {
// Configure environment
config();
loadEnvConfig();
// Reset old cache
await cleanupStorageCache(iconSetsStorage);
// Start HTTP server
startHTTPServer();
// Get all importers and load data
const importers = await getImporters();
for (let i = 0; i < importers.length; i++) {
await importers[i].init();
}
setImporters(importers);
updateIconSets();
// Init API
await initAPI();
// Loaded
loaded();

20
src/init.ts Normal file
View File

@ -0,0 +1,20 @@
import { getImporters } from './config/icon-sets.js';
import { iconSetsStorage } from './data/icon-set/store/storage.js';
import { setImporters, updateIconSets } from './data/icon-sets.js';
import { cleanupStorageCache } from './data/storage/startup.js';
/**
* Init API
*/
export async function initAPI() {
// Reset old cache
await cleanupStorageCache(iconSetsStorage);
// Get all importers and load data
const importers = await getImporters();
for (let i = 0; i < importers.length; i++) {
await importers[i].init();
}
setImporters(importers);
updateIconSets();
}