Skip to content

Commit aae12f8

Browse files
authored
Merge pull request #23 from thaddeus/lite-api
Lite api
2 parents 3427be3 + 6f970db commit aae12f8

File tree

4 files changed

+207
-36
lines changed

4 files changed

+207
-36
lines changed

graphql-yoga.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import useTwitch from './plugins/plugin-twitch.mjs';
1313
import useNightbot from './plugins/plugin-nightbot.mjs';
1414
import usePlayground from './plugins/plugin-playground.mjs';
1515
import useOptionMethod from './plugins/plugin-option-method.mjs';
16+
import useLiteApi from './plugins/plugin-lite-api.mjs';
1617

1718
let dataAPI, yoga;
1819

@@ -46,6 +47,7 @@ export default async function getYoga(env) {
4647
useTwitch(env),
4748
usePlayground(),
4849
useNightbot(env),
50+
useLiteApi(env),
4951
useHttpServer(env),
5052
useCacheMachine(env),
5153
],

index.mjs

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import graphQLOptions from './utils/graphql-options.mjs';
2626
import cacheMachine from './utils/cache-machine.mjs';
2727
import fetchWithTimeout from './utils/fetch-with-timeout.mjs';
2828

29-
import { getNightbotResponse } from './plugins/plugin-nightbot.mjs';
29+
import { getNightbotResponse, nightbotPaths } from './plugins/plugin-nightbot.mjs';
3030
import { getTwitchResponse } from './plugins/plugin-twitch.mjs';
31+
import { getLiteApiResponse, liteApiPathRegex } from './plugins/plugin-lite-api.mjs';
3132

3233
let dataAPI;
3334

@@ -108,32 +109,6 @@ async function graphqlHandler(request, env, ctx) {
108109
//console.log(`Skipping cache in ${ENVIRONMENT} environment`);
109110
}
110111

111-
// if an HTTP GraphQL server is configured, pass the request to that
112-
if (env.USE_ORIGIN === 'true') {
113-
try {
114-
const serverUrl = `https://api.tarkov.dev${graphQLOptions.baseEndpoint}`;
115-
const queryResult = await fetchWithTimeout(serverUrl, {
116-
method: request.method,
117-
body: JSON.stringify({
118-
query,
119-
variables,
120-
}),
121-
headers: {
122-
'Content-Type': 'application/json',
123-
'cache-check-complete': 'true',
124-
},
125-
timeout: 20000
126-
});
127-
if (queryResult.status !== 200) {
128-
throw new Error(`${queryResult.status} ${await queryResult.text()}`);
129-
}
130-
console.log('Request served from graphql server');
131-
return new Response(await queryResult.text(), responseOptions);
132-
} catch (error) {
133-
console.error(`Error getting response from GraphQL server: ${error}`);
134-
}
135-
}
136-
137112
const context = graphqlUtil.getDefaultContext(dataAPI, requestId);
138113
let result, ttl;
139114
try {
@@ -221,18 +196,34 @@ export default {
221196
response = graphiql(graphQLOptions);
222197
}
223198

224-
if (graphQLOptions.forwardUnmatchedRequestsToOrigin) {
225-
return fetch(request);
199+
// if an origin server is configured, pass the request
200+
if (env.USE_ORIGIN === 'true') {
201+
try {
202+
response = await fetchWithTimeout(request.clone(), {
203+
headers: {
204+
'cache-check-complete': 'true',
205+
},
206+
timeout: 20000
207+
});
208+
if (response.status !== 200) {
209+
throw new Error(`${response.status} ${await response.text()}`);
210+
}
211+
console.log('Request served from origin server');
212+
} catch (error) {
213+
console.error(`Error getting response from origin server: ${error}`);
214+
response = undefined;
215+
}
226216
}
227217

228-
if (url.pathname === '/webhook/nightbot' ||
229-
url.pathname === '/webhook/stream-elements' ||
230-
url.pathname === '/webhook/moobot'
231-
) {
218+
if (!response && nightbotPaths.includes(url.pathname)) {
232219
response = await getNightbotResponse(request, url, env, ctx);
233220
}
234221

235-
if (url.pathname === graphQLOptions.baseEndpoint) {
222+
if (!response && url.pathname.match(liteApiPathRegex)) {
223+
response = await getLiteApiResponse(request, url, env, ctx);
224+
}
225+
226+
if (!response && url.pathname === graphQLOptions.baseEndpoint) {
236227
response = await graphqlHandler(request, env, ctx);
237228
if (graphQLOptions.cors) {
238229
setCors(response, graphQLOptions.cors);

plugins/plugin-lite-api.mjs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import cacheMachine from '../utils/cache-machine.mjs';
2+
import DataSource from '../datasources/index.mjs';
3+
import graphqlUtil from '../utils/graphql-util.mjs';
4+
5+
export const liteApiPathRegex = /\/api\/v1(?<gameMode>\/\w+)?\/(?<endpoint>item[\w\/]*)/;
6+
7+
const currencyMap = {
8+
RUB: '₽',
9+
USD: '$',
10+
EUR: '€',
11+
};
12+
13+
export async function getLiteApiResponse(request, url, env, serverContext) {
14+
let q, lang, uid, tags, sort, sort_direction;
15+
if (request.method.toUpperCase() === 'GET') {
16+
q = url.searchParams.get('q');
17+
lang = url.searchParams.get('lang') ?? 'en';
18+
uid = url.searchParams.get('uid');
19+
tags = url.searchParams.get('tags')?.split(',') ?? [];
20+
sort = url.searchParams.get('sort');
21+
sort_direction = url.searchParams.get('sort_direction');
22+
} else if (request.method.toUpperCase() === 'POST') {
23+
const body = await request.json();
24+
q = body.q;
25+
lang = body.lang ?? 'en';
26+
uid = body.uid;
27+
tags = body.tags?.split(',') ?? [];
28+
sort = body.sort;
29+
sort_direction = body.sort_direction;
30+
} else {
31+
return new Response(null, {
32+
status: 405,
33+
headers: { 'cache-control': 'public, max-age=2592000' },
34+
});
35+
}
36+
37+
const pathInfo = url.pathname.match(liteApiPathRegex);
38+
39+
const gameMode = pathInfo.groups.gameMode || 'regular';
40+
41+
const endpoint = pathInfo.groups.endpoint;
42+
43+
let key;
44+
if (env.SKIP_CACHE !== 'true' && !request.headers.has('cache-check-complete')) {
45+
const requestStart = new Date();
46+
key = await cacheMachine.createKey(env, url.pathname, { q, lang, gameMode, uid, tags, sort, sort_direction });
47+
const cachedResponse = await cacheMachine.get(env, {key});
48+
if (cachedResponse) {
49+
// Construct a new response with the cached data
50+
const newResponse = new Response(cachedResponse);
51+
// Add a custom 'X-CACHE: HIT' header so we know the request hit the cache
52+
newResponse.headers.append('X-CACHE', 'HIT');
53+
console.log(`Request served from cache: ${new Date() - requestStart} ms`);
54+
// Return the new cached response
55+
request.cached = true;
56+
return newResponse;
57+
} else {
58+
console.log('no cached response');
59+
}
60+
} else {
61+
//console.log(`Skipping cache in ${ENVIRONMENT} environment`);
62+
}
63+
const data = new DataSource(env);
64+
const context = graphqlUtil.getDefaultContext(data);
65+
66+
const info = graphqlUtil.getGenericInfo(lang, gameMode);
67+
68+
function toLiteApiItem(item) {
69+
const bestTraderSell = item.traderPrices.reduce((best, current) => {
70+
if (!best || current.priceRUB > best.priceRUB) {
71+
return current;
72+
}
73+
return best;
74+
}, undefined);
75+
return {
76+
uid: item.id,
77+
name: data.worker.item.getLocale(item.name, context, info),
78+
tags: item.types,
79+
shortName: data.worker.item.getLocale(item.shortName, context, info),
80+
price: item.lastLowPrice,
81+
basePrice: item.basePrice,
82+
avg24hPrice: item.avg24hPrice,
83+
//avg7daysPrice: null,
84+
traderName: bestTraderSell ? bestTraderSell.name : null,
85+
traderPrice: bestTraderSell ? bestTraderSell.price : null,
86+
traderPriceCur: bestTraderSell ? currencyMap[bestTraderSell.currency] : null,
87+
updated: item.updated,
88+
slots: item.width * item.height,
89+
diff24h: item.changeLast48h,
90+
//diff7days: null,
91+
icon: item.iconLink,
92+
link: item.link,
93+
wikiLink: item.wikiLink,
94+
img: item.gridImageLink,
95+
imgBig: item.inspectImageLink,
96+
img512: item.image512pxLink,
97+
image8x: item.image8xLink,
98+
bsgId: item.id,
99+
isFunctional: true, // !item.types.includes('gun'),
100+
reference: 'https://tarkov.dev',
101+
};
102+
}
103+
104+
let items, ttl;
105+
const responseOptions = {
106+
headers: {
107+
'Content-Type': 'application/json',
108+
},
109+
};
110+
try {
111+
if (endpoint.startsWith('items')) {
112+
items = await data.worker.item.getAllItems(context, info);
113+
if (endpoint.endsWith('/download')) {
114+
responseOptions.headers['Content-Disposition'] = 'attachment; filename="items.json"';
115+
}
116+
if (tags) {
117+
items = await data.worker.item.getItemsByTypes(context, info, tags, items);
118+
}
119+
}
120+
if (!items && endpoint.startsWith('item')) {
121+
if (!q && !uid) {
122+
throw new Error('The item request requires either a q or uid parameter');
123+
}
124+
if (q) {
125+
items = await data.worker.item.getItemsByName(context, info, q);
126+
} else if (uid) {
127+
items = [await data.worker.item.getItem(context, info, uid)];
128+
}
129+
}
130+
items = items.map(toLiteApiItem);
131+
ttl = data.getRequestTtl(context.requestId);
132+
} catch (error) {
133+
return new Response(error.message, {status: 400});
134+
} finally {
135+
data.clearRequestData(context.requestId);
136+
}
137+
if (sort && items?.length) {
138+
items.sort((a, b) => {
139+
let aValue = sort_direction === 'desc' ? b[sort] : a[sort];
140+
let bValue = sort_direction === 'desc' ? a[sort] : b[sort];
141+
if (sort === 'updated') {
142+
aValue = new Date(aValue);
143+
bValue = new Date(bValue);
144+
}
145+
if (typeof aValue === 'string') {
146+
return aValue.localeCompare(bValue, lang);
147+
}
148+
return aValue - bValue;
149+
});
150+
}
151+
const responseBody = JSON.stringify(items ?? [], null, 4);
152+
153+
// Update the cache with the results of the query
154+
if (env.SKIP_CACHE !== 'true' && ttl > 0) {
155+
const putCachePromise = cacheMachine.put(env, responseBody, { key, query: url.pathname, variables: { q, lang, gameMode, uid, tags, sort, sort_direction }, ttl: String(ttl)});
156+
// using waitUntil doens't hold up returning a response but keeps the worker alive as long as needed
157+
if (request.ctx?.waitUntil) {
158+
request.ctx.waitUntil(putCachePromise);
159+
} else if (serverContext.waitUntil) {
160+
serverContext.waitUntil(putCachePromise);
161+
}
162+
}
163+
164+
return new Response(responseBody, responseOptions);
165+
}
166+
167+
export default function useLiteApi(env) {
168+
return {
169+
async onRequest({ request, url, endResponse, serverContext, fetchAPI }) {
170+
if (!url.pathname.match(liteApiPathRegex)) {
171+
return;
172+
}
173+
const response = await getLiteApiResponse(request, url, env, serverContext);
174+
175+
endResponse(response);
176+
},
177+
}
178+
}

plugins/plugin-nightbot.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ function capitalize(s) {
66
return s && s[0].toUpperCase() + s.slice(1);
77
}
88

9-
const usePaths = [
9+
export const nightbotPaths = [
1010
'/webhook/nightbot',
1111
'/webhook/stream-elements',
1212
'/webhook/moobot',
@@ -88,7 +88,7 @@ export async function getNightbotResponse(request, url, env, serverContext) {
8888
export default function useNightbot(env) {
8989
return {
9090
async onRequest({ request, url, endResponse, serverContext, fetchAPI }) {
91-
if (!usePaths.includes(url.pathname)) {
91+
if (!nightbotPaths.includes(url.pathname)) {
9292
return;
9393
}
9494
const response = await getNightbotResponse(request, url, env, serverContext);

0 commit comments

Comments
 (0)