diff --git a/.gitignore b/.gitignore index 30f252e77..57dea231d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ package-lock.json node_modules resources static/_gen -data/toc.json \ No newline at end of file +data/toc.json +api/dist +api/worker diff --git a/api/README.md b/api/README.md new file mode 100644 index 000000000..2d3d6543d --- /dev/null +++ b/api/README.md @@ -0,0 +1,9 @@ +## Usage + +Install Cloudflare Wrangler + +```bash +wrangler login # to get the token + +wrangler publish # to publish the worker to specs-api.protocol-labs.workers.dev +``` diff --git a/api/index.js b/api/index.js new file mode 100644 index 000000000..add3fedd3 --- /dev/null +++ b/api/index.js @@ -0,0 +1,162 @@ +const Router = require('./router') +const dlv = require('dlv') +const merge = require('merge-options') +const nanoid = require('nanoid/non-secure') + +const cacheBust = nanoid.nanoid() +/** + * Example of how router can be used in an application + * */ +addEventListener('fetch', (event) => { + event.respondWith(handleRequest(event)) +}) + +async function handleRequest(event) { + const r = new Router() + // Replace with the appropriate paths and handlers + r.get('.*/cov', () => cov(event)) + r.get('.*/github', () => github(event)) + r.get('/', () => new Response('Hello worker!')) // return a default message for the root route + + try { + return await r.route(event.request) + } catch (err) { + console.log('handleRequest -> err', err.stack) + return new Response(err.message, { + status: 500, + statusText: 'internal server error', + headers: { + 'content-type': 'text/plain', + }, + }) + } +} + +async function cov(event) { + const url = new URL(event.request.url) + // https://github.com/filecoin-project/lotus + const repo = url.searchParams.get('repo').split('/').slice(3).join('/') + const data = await get(event, { + url: `https://codecov.io/api/gh/${repo}`, + transform: (data) => { + const out = { + cov: dlv(data, 'commit.totals.c', 0), + ci: dlv(data, 'commit.ci_passed', false), + repo: dlv(data, 'repo.name', 'N/A'), + org: dlv(data, 'owner.username', 'N/A'), + lang: dlv(data, 'repo.language', 'N/A'), + } + return out + }, + }) + return data +} + +async function github(event) { + const url = new URL(event.request.url) + const file = url.searchParams.get('file').split('/') + // https://github.com/filecoin-project/lotus/blob/master/paychmgr/paych.go + const repo = file.slice(3, 5).join('/') + const path = file.slice(7).join('/') + const ref = file[6] + const headers = { + 'User-Agent': 'hugomrdias', + // Authorization: `token ${GITHUB_TOKEN}` + } + + const treeUrlRsp = await get(event, { + url: `https://api.github.com/repos/${repo}/commits?sha=${ref}&per_page=1&page=1`, + headers, + }) + const treeUrl = await treeUrlRsp.json() + + const data = await get(event, { + url: `https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}`, + transform: (data) => { + return { + content: data.content, + size: data.size, + url: `https://github.com/${repo}/tree/${treeUrl[0].sha}/${path}`, + } + }, + headers, + }) + return data +} + +async function get(event, options) { + const { url, transform, force, headers } = merge( + { + url: '', + transform: (d) => d, + force: false, + headers: {}, + }, + options + ) + + const cache = caches.default + const cacheKey = url + cacheBust + const cacheTTL = 86400 * 2 // 2 days + const cacheRevalidateTTL = 3600 * 2 // 2 hours + const cachedResponse = await cache.match(cacheKey) + + if (force || !cachedResponse) { + console.log('Cache miss for ', cacheKey) + // if not in cache get from the origin + const response = await fetch(url, { + headers: { + ...headers, + 'If-None-Match': cachedResponse + ? cachedResponse.headers.get('ETag') + : null, + }, + }) + + if (response.ok) { + const { headers } = response + const contentType = headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + // transform the data + const data = transform(await response.json()) + + // build new response with the transformed body + const transformedResponse = new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + 'Cache-Control': `max-age=${cacheTTL}`, + 'X-RateLimit-Limit': headers.get('X-RateLimit-Limit'), + 'X-RateLimit-Remaining': headers.get('X-RateLimit-Remaining'), + 'X-RateLimit-Reset': headers.get('X-RateLimit-Reset'), + ETag: headers.get('ETag'), + }, + }) + + // save response to cache + event.waitUntil(cache.put(cacheKey, transformedResponse.clone())) + + return transformedResponse + } else { + throw new Error( + `Request error content type not supported. ${contentType}` + ) + } + } else if (response.status === 304) { + // renew cache response + event.waitUntil(cache.put(cacheKey, cachedResponse.clone())) + return cachedResponse.clone() + } else { + return response + } + } else { + console.log('Cache hit for ', cacheKey, cachedResponse.headers.get('age')) + const cacheAge = cachedResponse.headers.get('age') + + if (cacheAge > cacheRevalidateTTL) { + console.log('Cache is too old, revalidating...') + event.waitUntil(get(event, { url, transform, force: true })) + } + return cachedResponse + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 000000000..b22fca261 --- /dev/null +++ b/api/package.json @@ -0,0 +1,16 @@ +{ + "name": "specs-api", + "version": "1.0.0", + "description": "spec proxy api", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Hugo Dias ", + "license": "MIT", + "dependencies": { + "dlv": "^1.1.3", + "merge-options": "^3.0.3", + "nanoid": "^3.1.16" + } +} diff --git a/api/router.js b/api/router.js new file mode 100644 index 000000000..3f88c6d76 --- /dev/null +++ b/api/router.js @@ -0,0 +1,121 @@ +/** + * Helper functions that when passed a request will return a + * boolean indicating if the request uses that HTTP method, + * header, host or referrer. + */ +const Method = (method) => (req) => + req.method.toLowerCase() === method.toLowerCase() +const Connect = Method('connect') +const Delete = Method('delete') +const Get = Method('get') +const Head = Method('head') +const Options = Method('options') +const Patch = Method('patch') +const Post = Method('post') +const Put = Method('put') +const Trace = Method('trace') + +const Header = (header, val) => (req) => req.headers.get(header) === val +const Host = (host) => Header('host', host.toLowerCase()) +const Referrer = (host) => Header('referrer', host.toLowerCase()) + +const Path = (regExp) => (req) => { + const url = new URL(req.url) + const path = url.pathname + const match = path.match(regExp) || [] + return match[0] === path +} + +/** + * The Router handles determines which handler is matched given the + * conditions present for each request. + */ +class Router { + constructor() { + this.routes = [] + } + + handle(conditions, handler) { + this.routes.push({ + conditions, + handler, + }) + return this + } + + connect(url, handler) { + return this.handle([Connect, Path(url)], handler) + } + + delete(url, handler) { + return this.handle([Delete, Path(url)], handler) + } + + get(url, handler) { + return this.handle([Get, Path(url)], handler) + } + + head(url, handler) { + return this.handle([Head, Path(url)], handler) + } + + options(url, handler) { + return this.handle([Options, Path(url)], handler) + } + + patch(url, handler) { + return this.handle([Patch, Path(url)], handler) + } + + post(url, handler) { + return this.handle([Post, Path(url)], handler) + } + + put(url, handler) { + return this.handle([Put, Path(url)], handler) + } + + trace(url, handler) { + return this.handle([Trace, Path(url)], handler) + } + + all(handler) { + return this.handle([], handler) + } + + route(req) { + const route = this.resolve(req) + + if (route) { + return route.handler(req) + } + + return new Response('resource not found', { + status: 404, + statusText: 'not found', + headers: { + 'content-type': 'text/plain', + }, + }) + } + + /** + * resolve returns the matching route for a request that returns + * true for all conditions (if any). + */ + resolve(req) { + return this.routes.find((r) => { + if (!r.conditions || (Array.isArray(r) && !r.conditions.length)) { + return true + } + + if (typeof r.conditions === 'function') { + return r.conditions(req) + } + + return r.conditions.every((c) => c(req)) + }) + } +} + +module.exports = Router diff --git a/api/wrangler.toml b/api/wrangler.toml new file mode 100644 index 000000000..72bc691e0 --- /dev/null +++ b/api/wrangler.toml @@ -0,0 +1,6 @@ +name = "specs-api" +type = "webpack" +account_id = "fffa4b4363a7e5250af8357087263b3a" +workers_dev = true +route = "" +zone_id = "" diff --git a/config.toml b/config.toml index 8889afc82..18de27b79 100644 --- a/config.toml +++ b/config.toml @@ -41,7 +41,7 @@ enableGitInfo = true # weight = 10 [params] - API = 'https://specs-api.hugomrdias.workers.dev' + API = 'https://specs-api.protocol-labs.workers.dev' # (Optional, default true) Controls table of contents visibility on right side of pages. # Start and end levels can be controlled with markup.tableOfContents setting. # You can also specify this parameter per page in front matter.