├── .babelrc ├── .dockerignore ├── .github └── workflows │ ├── format.yml │ └── test.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── package.json ├── src ├── bugsnag.js ├── index.js ├── v0.1 │ ├── README.md │ ├── airtable-info.yml │ ├── index.js │ └── utils.js ├── v0.2 │ ├── README.md │ ├── auth │ │ ├── game-lab.yml │ │ ├── live-editor-projects.yml │ │ ├── muse.yml │ │ ├── post_boy.yml │ │ ├── public.yml │ │ ├── slash-z-msw-dev.yml │ │ ├── sprig-order.yml │ │ ├── template.yml │ │ └── test.yml │ ├── index.js │ └── permissions.js └── v0 │ ├── README.md │ ├── allowlist.js │ ├── api2.jpg │ ├── index.js │ └── utils.js ├── tests ├── README.md ├── general-routes.test.js ├── sample.test.js ├── v0.1 │ ├── allowlist.test.js │ ├── cache.test.js │ └── routes.test.js ├── v0.2 │ ├── auth-files.test.js │ ├── auth-test-file.test._js │ └── routes.test._js └── v0 │ └── routes.test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime", 5 | "@babel/plugin-proposal-optional-chaining", 6 | "@babel/plugin-proposal-export-default-from" 7 | ] 8 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-debug.log* 3 | yarn-error.log* 4 | .git 5 | .gitignore 6 | .env 7 | *.md 8 | .DS_Store -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: format 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | cache: 'yarn' 13 | - run: yarn install --frozen-lockfile 14 | - run: yarn fmtCheck 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | basic-test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | cache: 'yarn' 13 | - run: yarn install --frozen-lockfile 14 | - run: yarn run basic-test 15 | env: 16 | CI: true 17 | AIRTABLE_API_KEY: FAKE_KEY 18 | 19 | production-test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | cache: 'yarn' 26 | node-version: 'latest' 27 | - run: yarn install --frozen-lockfile 28 | - run: yarn run production-test 29 | env: 30 | CI: true 31 | AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .env 4 | *.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "tabWidth": 2, 4 | "useTabs": false 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js LTS (Long Term Support) as base image 2 | FROM node:18-slim 3 | 4 | # Install curl for coolify healthchecks 5 | RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* 6 | 7 | 8 | # Create app directory 9 | WORKDIR /app 10 | 11 | # Copy package.json and yarn.lock 12 | COPY package.json yarn.lock ./ 13 | 14 | # Install dependencies 15 | RUN yarn install --frozen-lockfile 16 | 17 | # Copy source code 18 | COPY . . 19 | 20 | # Build the application 21 | RUN yarn build 22 | 23 | # Expose the port (you'll need to set this in your environment) 24 | EXPOSE 5000 25 | 26 | # Start the application 27 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Airbridge

2 | 3 | 4 |

The bridges tying Hack Club's services together. (WIP) Illustrated below by @maxwofford.

5 |

Raft icon 6 | 7 | 8 | [![test](https://github.com/hackclub/airbridge/actions/workflows/test.yml/badge.svg)](https://github.com/hackclub/airbridge/actions/workflows/test.yml) 9 | 10 | [![format](https://github.com/hackclub/airbridge/actions/workflows/format.yml/badge.svg)](https://github.com/hackclub/airbridge/actions/workflows/format.yml) 11 | 12 | ## Reasoning 13 | 14 | Our [previous API](https://github.com/hackclub/api/blob/master/README.md) was really good at a couple things. It hasn't been touched in years and it's still providing password-less authentication as a service at scale. 15 | 16 | Hack Club (HQ & community) needs a service for easily reading & writing information that will last the test of time the same way our original API still handles authentication. Airbridge will create this by providing a JSON interface to an Airtable backend. 17 | 18 | ## Try the latest version here: [v0.1](./src/v0.1/README.md) 19 | 20 | Version list: 21 | 22 | - [v0.2 (in development)](./src/v0.2/README.md) 23 | - [v0.1](./src/v0.1/README.md) 24 | - [v0](./src/v0/README.md) 25 | 26 | ## Developing & Contributing 27 | > The Airtable PAT (Personal Access Token) is under logins+dinobox@hackclub.com airtable account. 28 | 29 | ```sh 30 | # Set it up locally 31 | git clone https://github.com/hackclub/airbridge && cd airbridge 32 | yarn 33 | 34 | # Run locally with nodemon 35 | yarn dev # then, go to localhost:5000/ping in your browser 36 | 37 | # Run tests 38 | yarn test 39 | 40 | # Run specific tests 41 | yarn test tests/v0/routes.test.js # (your choice of testfile here) 42 | ``` 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackclub-info", 3 | "version": "0.2.0", 4 | "description": "Public-facing API for getting info from Hack Club", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "PORT=5000 nodemon --exec babel-node src/index.js", 8 | "start": "node build/index.js", 9 | "build": "babel src -d build --copy-files", 10 | "heroku-postbuild": "npm run build", 11 | "fmt": "prettier \"{tests/**,src/**,}/*.js\" --write", 12 | "fmtCheck": "prettier \"{tests/**,src/**,}/*.js\" --check", 13 | "test": "jest", 14 | "production-test": "jest -t='production'", 15 | "basic-test": "jest -t='basic' --detectOpenHandles" 16 | }, 17 | "keywords": [], 18 | "author": "Max Wofford ", 19 | "license": "MIT", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/hackclub/api2.git" 23 | }, 24 | "dependencies": { 25 | "@babel/runtime": "^7.7.7", 26 | "@bugsnag/js": "^6.5.0", 27 | "@bugsnag/plugin-express": "^6.5.0", 28 | "airtable": "^0.12.2", 29 | "cors": "^2.8.5", 30 | "express": "^4.17.3", 31 | "friendly-words": "^1.1.10", 32 | "js-yaml": "^4.1.0", 33 | "node-cache": "^5.1.2", 34 | "node-fetch": "^2" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.6.4", 38 | "@babel/core": "^7.6.4", 39 | "@babel/node": "^7.6.3", 40 | "@babel/plugin-proposal-export-default-from": "^7.8.3", 41 | "@babel/plugin-proposal-optional-chaining": "^7.9.0", 42 | "@babel/plugin-transform-runtime": "^7.7.6", 43 | "@babel/preset-env": "^7.6.3", 44 | "dotenv": "^8.1.0", 45 | "jest": "^24.9.0", 46 | "nodemon": "^1.19.3", 47 | "prettier": "^2.0.5", 48 | "supertest": "^4.0.2" 49 | }, 50 | "jest": { 51 | "testEnvironment": "node", 52 | "coveragePathIgnorePatterns": [ 53 | "/node_modules/" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/bugsnag.js: -------------------------------------------------------------------------------- 1 | import bugsnag from '@bugsnag/js' 2 | import bugsnagExpress from '@bugsnag/plugin-express' 3 | 4 | if (process.env.BUGSNAG_API_KEY) { 5 | const bugsnagClient = bugsnag({ 6 | apiKey: process.env.BUGSNAG_API_KEY, 7 | notifyReleaseStages: [ 'production' ] 8 | }) 9 | bugsnagClient.use(bugsnagExpress) 10 | var bugsnagMiddleware = bugsnagClient.getPlugin('express') 11 | } else { 12 | console.log('No bugsnag api key provided, skipping configuration') 13 | 14 | // This stubs out bugsnag's methods so we can still load the bugsnag middleware 15 | var bugsnagMiddleware = { 16 | requestHandler: (req, res, next) => next(), 17 | errorHandler: (req, res, next) => next() 18 | } 19 | } 20 | 21 | export const bugsnagRequestHandler = bugsnagMiddleware.requestHandler || (() => true) 22 | export const bugsnagErrorHandler = bugsnagMiddleware.errorHandler || (() => true) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || "development" 2 | if (env === "development" || env === "test") { 3 | console.log("Not in production, configuring with .env") 4 | require("dotenv").config() 5 | } 6 | if (!process.env.AIRTABLE_API_KEY) { 7 | throw new Error("Missing AIRTABLE_API_KEY from environmental variables") 8 | } 9 | 10 | import { bugsnagErrorHandler, bugsnagRequestHandler } from "./bugsnag" 11 | import express from "express" 12 | import cors from "cors" 13 | 14 | const app = express() 15 | app.use(bugsnagRequestHandler) 16 | app.use(cors()) 17 | app.use(express.json()) 18 | 19 | app.get("/", (req, res) => { 20 | res.redirect(302, "https://github.com/hackclub/airbridge") 21 | }) 22 | 23 | app.get("/ping", (req, res) => { 24 | res.status(200).json({ message: "pong!" }) 25 | }) 26 | 27 | import routerV0 from "./v0" 28 | import routerV0_1 from "./v0.1" 29 | import routerV0_2 from "./v0.2" 30 | app.use("/v0", routerV0) 31 | app.use("/v0.1", routerV0_1) 32 | app.use("/v0.2", routerV0_2) 33 | 34 | export const server = app.listen(process.env.PORT || 0, () => 35 | console.log(`Up and listening on ${server.address().port}`) 36 | ) 37 | 38 | app.use(bugsnagErrorHandler) 39 | -------------------------------------------------------------------------------- /src/v0.1/README.md: -------------------------------------------------------------------------------- 1 |

V0.1

2 |

The bridges tying Hack Club's services together. Illustrated below.

3 |

Raft icon 4 | 5 | # Usage 6 | 7 | API2 relies on JSON passed in the `select` url param. When I write: 8 | 9 | ```js 10 | // Operations/Clubs 11 | { 12 | maxRecords: 1, 13 | fields: ["Name", "Latitude"] 14 | } 15 | ``` 16 | 17 | you should try this: 18 | 19 | ```sh 20 | curl https://api2.hackclub.com/v0.1/Operations/Clubs?select={"maxRecords":1,"fields":["Name","Latitude"]} 21 | ``` 22 | 23 | ## The allowlist 24 | 25 | You can use v0.1 without authentication. Public data is in [this file](./airtable-info.yml). If there are additional Airtable bases or fields you'd like access to, feel free to submit a PR to add them. 26 | 27 | If you want to authenticate & get past the allowlist, you'll need an API token from Hack Club staff. To use the token, you must pass it through the params. For example: 28 | 29 | ```sh 30 | curl https://api2.hackclub.com/v0.1/Operations/People/?authKey=YOUR_TOKEN&select={"maxRecords":1} 31 | ``` 32 | -------------------------------------------------------------------------------- /src/v0.1/airtable-info.yml: -------------------------------------------------------------------------------- 1 | --- 2 | YOUR_AIRTABLE_NAME: 3 | baseID: YOUR_AIRTABLE_BASE_ID 4 | YOUR_BASE: 5 | - ALLOWLISTED_FIELD_1 6 | - ALLOWLISTED_FIELD_2 7 | - ALLOWLISTED_FIELD_3 8 | # Feel free to PR your own changes in following the example above ^ 9 | Unified YSWS Projects DB: 10 | baseID: app3A5kJwYqxMLOgh 11 | YSWS Programs: 12 | - Name 13 | - Unweighted–Total 14 | Assemble: 15 | baseID: appO6uRegNaw9bfPQ 16 | Prompts: 17 | - Prompt 18 | Airbridge: 19 | baseID: appP3uDe6tFt7cA5r 20 | Authtokens: 21 | - List File Name 22 | Saved Projects: 23 | baseID: appYdkBrT3PrwcbJB 24 | Muse Projects: 25 | - Content 26 | - Link 27 | - Public 28 | - Name 29 | Live Editor Projects: 30 | - Content 31 | - Link 32 | - Public 33 | - Name 34 | Club Applications: 35 | baseID: appSUAc40CDu6bDAp 36 | Clubs Dashboard: 37 | - Venue 38 | - Latitude 39 | - Longitude 40 | - Status 41 | golang-z: 42 | baseID: appsbb0wd5N9AbSP0 43 | Meetings: 44 | - Status 45 | - Unique Users Joined 46 | hackathons.hackclub.com: 47 | baseID: apptapPDAi0eBaaG1 48 | applications: 49 | - name 50 | - website 51 | - start 52 | - end 53 | - parsed_city 54 | - parsed_state_code 55 | - parsed_country 56 | - parsed_country_code 57 | - apac 58 | - hackclub_affiliated 59 | - mlh_associated 60 | - logo 61 | - banner 62 | - approved 63 | - virtual 64 | - hybrid 65 | - id 66 | - created_at 67 | - lat 68 | - lng 69 | Operations: 70 | baseID: apptEEFG5HTfGQE7h 71 | Clubs: 72 | - Name 73 | - Slack Channel ID 74 | - Leader Slack IDs 75 | - Leader Names 76 | - Address City 77 | - Address State 78 | - Address Postal Code 79 | - Address Country 80 | - Meeting sizes 81 | - Club URL 82 | - Latitude 83 | - Longitude 84 | - Dummy 85 | - Acceptance Time 86 | Badges: 87 | - ID 88 | - Name 89 | - Emoji Tag 90 | - Icon 91 | - People Slack IDs 92 | Awards: 93 | - Nominees 94 | - Winner 95 | - Submission Deadline 96 | - Judge Full Name 97 | - GP Value 98 | Mail Missions: 99 | - Status 100 | - Mail Team Thread Timestamp 101 | - Receiver Country 102 | Command Center Schedule: 103 | baseID: appGvXhgsuXhCTrOr 104 | Schedule: 105 | - Session Name 106 | - Leader Name 107 | - Time (Eastern) 108 | - Date (formatted) 109 | - Time (formatted) 110 | - Calendar Event 111 | - Preview Image 112 | Sessions: 113 | baseID: appezi7TOQFt8vTfa 114 | Events: 115 | - Title 116 | - Description 117 | - Leader 118 | - Leader Slack ID 119 | - Start Time 120 | - End Time 121 | - Avatar 122 | - Emoji 123 | - Event Link 124 | - YouTube URL 125 | - AMA 126 | - AMA Name 127 | - AMA Company 128 | - AMA Title 129 | - AMA Link 130 | - AMA Id 131 | - AMA Avatar 132 | - Calendar Link 133 | - Tags 134 | - Approved 135 | - Canceled 136 | - Interested Users 137 | Users: 138 | - Slack ID 139 | - Interesting Events 140 | Draw in the dark: 141 | baseID: applcpliMnombEJb9 142 | Deadline: 143 | - Time 144 | - Preview 145 | - Art title 146 | - Active 147 | Bank Promotions: 148 | baseID: appEzv7w2IBMoxxHe 149 | Github Grant: 150 | - Status 151 | StickerMule: 152 | - Status 153 | Repl.it Hacker Plan: 154 | - Status 155 | Sendy: 156 | - Status 157 | Wire Transfers: 158 | - Status 159 | PVSA Order: 160 | - Status 161 | PayPal Transfers: 162 | - Status 163 | Bank Stickers: 164 | - Status 165 | Typeform: 166 | - Status 167 | Disputed Transactions: 168 | - Status 169 | Domains: 170 | - Status 171 | 1Password: 172 | - Status 173 | Feedback: 174 | - Status 175 | Wallets: 176 | - Status 177 | Event Insurance: 178 | - Status 179 | Google Workspace Waitlist: 180 | - Status 181 | Ships: 182 | baseID: appnzwlmqTft69NhW 183 | Ships: 184 | - Timestamp 185 | - User Name 186 | - User Avatar 187 | - User Website 188 | - Message 189 | - Image URL 190 | - Project URL 191 | Tutorial Island: 192 | baseID: appYGt7P3MtotTotg 193 | Tutorial Island: 194 | - Name 195 | - Flow 196 | - Pronouns 197 | SOM Sticker Requests: 198 | baseID: appnP4GV8lmAuOXpf 199 | Sticker Requests: 200 | - Country Dropdown 201 | - Created At 202 | Leaps in India: 203 | - Record ID 204 | Artwork: 205 | - Sticker Requests Count 206 | - Name 207 | - URL 208 | Exchange: 209 | baseID: appoBQQwL8ABXjasg 210 | Senders: 211 | - Country Dropdown 212 | - Created At 213 | SendBack: 214 | - Record ID 215 | SOM Hardware Party: 216 | baseID: appxfNQJLNfCdcyUN 217 | Players: 218 | - Name 219 | - Score 220 | Zephyr: 221 | baseID: appYNERZpoDo0XMUW 222 | Applications: 223 | - Username 224 | - Default Profile Picture 225 | Joins: 226 | baseID: appaqcJtn33vb59Au 227 | Join Requests: 228 | - Created At 229 | Bank Applications Database: 230 | baseID: apppALh5FEOKkhjLR 231 | Events: 232 | - Pending 233 | AssemblePreShow: 234 | baseID: appLYgFaVOeTHzvzE 235 | Images: 236 | - Prompt 237 | - DALL-E 238 | Sprig Waitlist: 239 | baseID: appN9cSnDgORAM2bp 240 | Requests: 241 | - Status 242 | Winter Hardware Wonderland: 243 | baseID: app1o9tRo6XulLnsr 244 | rsvp: 245 | - Status 246 | The Bin: 247 | baseID: appKjALSnOoA0EmPk 248 | RSVPs: 249 | - Status 250 | SlashZSamPoder: 251 | baseID: appILZk0gunMZ1J1i 252 | Submissions: 253 | - Emoji 254 | Black Box: 255 | baseID: app8aRVQaxBDJTTph 256 | YSWS Project Submission: 257 | - Code URL 258 | - Automation - Submit to Unified YSWS 259 | - Matrix Color 260 | - Slack Username 261 | - Project Identifier 262 | - Verb 263 | - Preview A 264 | - Preview B 265 | - Blue? 266 | Tarot: 267 | baseID: appOkhzTn4Z3FI9gv 268 | users: 269 | - slack_uid 270 | - hand 271 | moments: 272 | - slack_uid 273 | - project 274 | - status 275 | - duration_seconds 276 | speedrun_recordings: 277 | - creator_slack_id 278 | - start_date 279 | - end_date 280 | - status 281 | - project 282 | - duration_seconds 283 | - slack_thread_url 284 | MC Modding: 285 | baseID: appROpbCKgNm7r5ln 286 | Submissions: 287 | - Status 288 | - Code link 289 | - Play link 290 | - Demo video 291 | - Description 292 | Hacklet: 293 | baseID: appzZAYxJ1xm0wYdG 294 | Hacklet Project Submission: 295 | - Project Name 296 | - Code URL 297 | - Bookmarklet Code 298 | - Description 299 | Boba Drops: 300 | baseID: app05mIKwNPO2l1vT 301 | Websites: 302 | - Status 303 | - Code URL 304 | - Playable URL 305 | - Screenshot -------------------------------------------------------------------------------- /src/v0.1/index.js: -------------------------------------------------------------------------------- 1 | import { airtableCreate, airtableLookup, airtableUpdate } from "./utils.js" 2 | import NodeCache from "node-cache" 3 | import express from "express" 4 | const router = express.Router() 5 | const cache = new NodeCache() 6 | 7 | router.use((req, res, next) => { 8 | res.locals.start = Date.now() 9 | res.locals.showMeta = req.query.meta 10 | res.locals.response = {} 11 | res.locals.meta = { 12 | params: { ...req.params }, 13 | query: { ...req.query }, 14 | body: { ...res.body }, 15 | cache: { 16 | key: cacheKey(req), 17 | ...cache.getStats(), 18 | }, 19 | } 20 | 21 | if (req.query.authKey) { 22 | res.locals.authKey = req.query.authKey 23 | res.locals.meta.query.authKey = "[redacted]" 24 | } 25 | 26 | next() 27 | }) 28 | 29 | function cacheKey(req) { 30 | const { meta, cache, ...filteredQuery } = req.query 31 | return req.baseUrl + req._parsedUrl.pathname + JSON.stringify(filteredQuery) 32 | } 33 | 34 | function respond(err, req, res, next) { 35 | res.locals.meta.duration = Date.now() - res.locals.start 36 | res.locals.meta.params = { 37 | ...res.locals.meta.params, 38 | ...req.params, 39 | version: 0.1, 40 | } 41 | 42 | if (err) { 43 | const statusCode = err.statusCode || 500 44 | res.status(statusCode).send({ 45 | error: { ...err, message: err.message, statusCode }, 46 | meta: res.locals.meta, 47 | }) 48 | } else { 49 | if (res.locals.showMeta) { 50 | if (Array.isArray(res.locals.response)) { 51 | res.locals.meta.resultCount = res.locals.response.length 52 | } else { 53 | res.locals.meta.resultCount = 1 54 | } 55 | res.json({ 56 | response: res.locals.response, 57 | meta: res.locals.meta, 58 | }) 59 | } else { 60 | res.json(res.locals.response) 61 | } 62 | } 63 | 64 | if (!res.locals.meta.cache.pulledFrom && res.statusCode == 200) { 65 | const key = cacheKey(req) 66 | console.log("Saving result to my cache with key", key) 67 | cache.set(key, res.locals.response) 68 | } 69 | 70 | next() 71 | } 72 | 73 | router.post("/:base/:tableName", async (req, res, next) => { 74 | const options = { 75 | base: req.params.base, 76 | tableName: req.params.tableName, 77 | fields: req.body, 78 | } 79 | try { 80 | res.locals.response = await airtableCreate(options, req.query.authKey) 81 | respond(null, req, res, next) 82 | } catch (err) { 83 | respond(err, req, res, next) 84 | } 85 | }) 86 | 87 | router.patch("/:base/:tableName", async (req, res, next) => { 88 | const options = { 89 | base: req.params.base, 90 | tableName: req.params.tableName, 91 | record: req.body, 92 | } 93 | try { 94 | res.locals.response = await airtableUpdate(options, req.query.authKey) 95 | respond(null, req, res, next) 96 | } catch (err) { 97 | respond(err, req, res, next) 98 | } 99 | }) 100 | 101 | router.get("/:base/:tableName", async (req, res, next) => { 102 | const options = { 103 | base: req.params.base, 104 | tableName: req.params.tableName, 105 | } 106 | if (req.query.select) { 107 | try { 108 | options.select = JSON.parse(req.query.select) 109 | } catch (err) { 110 | respond(err, req, res, next) 111 | } 112 | } 113 | if (req.query.cache) { 114 | console.log("Cache flag enabled", cacheKey(req)) 115 | const cacheResult = cache.get(cacheKey(req)) 116 | if (cacheResult) { 117 | console.log("Found results in cache!") 118 | res.locals.meta.cache.pulledFrom = true 119 | res.locals.response = cacheResult 120 | respond(null, req, res, next) 121 | } else { 122 | console.log("Nothing found in cache!") 123 | try { 124 | res.locals.response = await airtableLookup(options, res.locals.authKey) 125 | respond(null, req, res, next) 126 | } catch (err) { 127 | respond(err, req, res, next) 128 | } 129 | } 130 | } else { 131 | try { 132 | res.locals.response = await airtableLookup(options, res.locals.authKey) 133 | respond(null, req, res, next) 134 | } catch (err) { 135 | respond(err, req, res, next) 136 | } 137 | } 138 | }) 139 | 140 | export default router 141 | -------------------------------------------------------------------------------- /src/v0.1/utils.js: -------------------------------------------------------------------------------- 1 | import Airtable from "airtable" 2 | import friendlyWords from "friendly-words" 3 | import fetch from "node-fetch" 4 | import yaml from "js-yaml" 5 | import path from "path" 6 | import fs from "fs" 7 | 8 | const allowlist = (() => { 9 | try { 10 | const doc = yaml.load( 11 | fs.readFileSync(path.resolve(__dirname, "./airtable-info.yml"), "utf8") 12 | ) 13 | return doc 14 | } catch (e) { 15 | console.error(e) 16 | } 17 | })() 18 | 19 | function lookupBaseID(name) { 20 | return allowlist[name]?.baseID || name 21 | } 22 | 23 | function lookupBase(id) { 24 | return Object.values(allowlist).find((e) => e.baseID == id) 25 | } 26 | 27 | function lookupTable(baseData, name) { 28 | if (baseData.hasOwnProperty(name) && name != "baseID") { 29 | return baseData[name] 30 | } else { 31 | return null 32 | } 33 | } 34 | 35 | function allowlistBaseTable(baseID, tableName) { 36 | const baseInAllowlist = lookupBase(baseID) 37 | if (!baseInAllowlist) { 38 | const err = new Error( 39 | "Not found: base either doesn't exist or isn't publicly accessible" 40 | ) 41 | err.statusCode = 404 42 | throw err 43 | } else { 44 | console.log("Publicly accessing base", baseID) 45 | } 46 | 47 | const tableInAllowlist = lookupTable(baseInAllowlist, tableName) 48 | if (!tableInAllowlist) { 49 | const err = new Error( 50 | "Not found: table either doesn't exist or isn't publicly accessible" 51 | ) 52 | err.statusCode = 404 53 | throw err 54 | } else { 55 | console.log("Publicly accessing table", tableName) 56 | } 57 | return tableInAllowlist 58 | } 59 | 60 | function allowlistedRecords(records, allowlistedFields) { 61 | if (Array.isArray(records)) { 62 | return records.map((r) => allowlistedRecords(r, allowlistedFields)) 63 | } else { 64 | const record = records 65 | const result = { 66 | id: record.id, 67 | fields: {}, 68 | } 69 | 70 | allowlistedFields.forEach( 71 | (field) => (result.fields[field] = record.fields[field]) 72 | ) 73 | return result 74 | } 75 | } 76 | 77 | export async function airtableLookup(options, auth) { 78 | const { base, tableName, select } = options 79 | const baseID = lookupBaseID(base) 80 | 81 | if (auth) { 82 | const airinst = new Airtable({ apiKey: auth }).base(baseID)(tableName) 83 | const rawResults = await airinst.select(select).all() 84 | return rawResults.map((result) => ({ 85 | id: result.id, 86 | fields: result.fields, 87 | })) 88 | } else { 89 | const allowlistedFields = allowlistBaseTable(baseID, tableName, auth) 90 | 91 | let resultFields = [] 92 | if (select && Array.isArray(select.fields)) { 93 | resultFields = allowlistedFields.filter((f) => select.fields.includes(f)) 94 | } else { 95 | resultFields = allowlistedFields 96 | } 97 | 98 | const airinst = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base( 99 | baseID 100 | )(tableName) 101 | 102 | const rawResults = await airinst 103 | .select({ ...select, fields: resultFields }) 104 | .all() 105 | 106 | return allowlistedRecords(rawResults, resultFields) 107 | } 108 | } 109 | 110 | export async function airtableUpdate(options, auth) { 111 | const { base, tableName, record } = options 112 | const baseID = lookupBaseID(base) 113 | if (auth) { 114 | if (!record.id) { 115 | const err = new Error("Unable to complete request: invalid patch format") 116 | err.statusCode = 422 117 | throw err 118 | } 119 | return new Promise((resolve, reject) => { 120 | const airinst = new Airtable({ apiKey: auth }).base(baseID)(tableName) 121 | airinst.update(record.id, record.fields, (err, updatedRecords) => { 122 | if (err) { 123 | console.error(err) 124 | reject(err) 125 | } 126 | resolve(updatedRecords) 127 | }) 128 | }) 129 | } else { 130 | const err = new Error( 131 | "Unable to complete request: patching requires authentication" 132 | ) 133 | err.statusCode = 401 134 | throw err 135 | } 136 | } 137 | 138 | export async function airtableCreate(options, auth) { 139 | const { base, tableName, fields } = options 140 | const baseID = lookupBaseID(base) 141 | 142 | if (auth) { 143 | return new Promise((resolve, reject) => { 144 | const airinst = new Airtable({ apiKey: auth }).base(baseID)(tableName) 145 | airinst.create(fields, (err, records) => { 146 | if (err) { 147 | console.error(err) 148 | reject(err) 149 | } 150 | resolve(records) 151 | }) 152 | }) 153 | } else { 154 | const err = new Error( 155 | "Unable to complete request: posting requires authentication" 156 | ) 157 | err.statusCode = 401 158 | throw err 159 | } 160 | } 161 | 162 | function randomName() { 163 | const { predicates, objects } = friendlyWords 164 | const predicate = predicates[predicates.length * Math.random() - 1] 165 | const object = objects[objects.length * Math.random() - 1] 166 | 167 | return `${predicate}-${object}` 168 | } 169 | 170 | export async function fileToTempURL(blob, name = randomName()) { 171 | const formData = new FormData() 172 | formData.append("input_file", blob, name) 173 | formData.append("max_views", 0) 174 | formData.append("max_minutes", 1) 175 | formData.append("upl", "Upload") 176 | 177 | const response = await fetch( 178 | "https://cors-anywhere.herokuapp.com/https://tmpfiles.org/?upload", 179 | { 180 | method: "POST", 181 | mode: "cors", 182 | body: formData, 183 | } 184 | ) 185 | 186 | if (response.headers) { 187 | return res.headers.get("X-Final-Url").replace("download", "dl") 188 | } else { 189 | throw "Unable to create file" 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/v0.2/README.md: -------------------------------------------------------------------------------- 1 |

V0.2

2 |

The bridges tying Hack Club's services together. Illustrated below.

3 |

Raft icon 4 | 5 | # What's new? 6 | 7 | **tl;dr this is update with scoped access and special Airbridge API tokens** 8 | 9 | V0.2 brings in the concept of [auth files](./auth). You can check out a [starter template](./auth/template.yml) if you want to write your own. 10 | 11 | Authenticated vs unauthenticated requests are simplified now. If you don't authenticate your request, you'll automatically be authenticated to the [public auth file](./auth/public.yml). If you need access to fields or tables that shouldn't be publicly viewable, you can make your own auth file. You'll then be given an `Airbridge API Key` by a staff member (please ping us so we get back to you quickly!) you can use to access the private data listed in your auth file. 12 | 13 | Is there a field you want access to & are fine with other people seeing it? Add it to the [public auth file](./auth/public.yml) so you can pull the data from Airbridge without having to use any API keys. 14 | 15 | There's also a new route for looking up specific records: `https://airbrige.hackclub.com/v0.2/BASE/TABLE/RECORD_ID`. 16 | 17 | Oh, last but not least, new name! `api2` -> `airbridge`. Your old services will still work; old requests to api2.hackclub.com will be redirected to airbridge.hackclub.com. 18 | 19 | # Usage 20 | 21 | Airbridge relies on JSON passed in the `select` url param. When I write: 22 | 23 | ```js 24 | // Operations/Clubs 25 | { 26 | maxRecords: 1, 27 | fields: ["Name", "Latitude"] 28 | } 29 | ``` 30 | 31 | you should try this: 32 | 33 |

34 | Browser example 35 | 36 | ```js 37 | fetch('https://api2.hackclub.com/v0.2/Operations/Clubs?select={"maxRecords":1,"fields":["Name","Latitude"]}').then(res => console.log(res)) 38 | ``` 39 |
40 |
41 | Terminal example 42 | 43 | ```sh 44 | curl 'https://api2.hackclub.com/v0.2/Operations/Clubs?select={"maxRecords":1,"fields":["Name","Latitude"]}' 45 | ``` 46 |
47 | 48 | # Development -------------------------------------------------------------------------------- /src/v0.2/auth/game-lab.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Saved Projects: 3 | baseID: appYdkBrT3PrwcbJB 4 | Game Lab: 5 | post: 6 | - Name 7 | - JSON 8 | get: 9 | - Name 10 | - JSON 11 | - Link 12 | -------------------------------------------------------------------------------- /src/v0.2/auth/live-editor-projects.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Saved Projects: 3 | baseID: appYdkBrT3PrwcbJB 4 | Live Editor Projects: 5 | post: 6 | - Name 7 | - Content 8 | get: 9 | - Name 10 | - Content 11 | - Public 12 | - Link 13 | -------------------------------------------------------------------------------- /src/v0.2/auth/muse.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Saved Projects: 3 | baseID: appYdkBrT3PrwcbJB 4 | Muse Projects: 5 | post: 6 | - Content 7 | - Name 8 | - Request to share publicly? 9 | get: 10 | - Content 11 | - Link 12 | - Public 13 | -------------------------------------------------------------------------------- /src/v0.2/auth/post_boy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Operations: 3 | baseID: apptEEFG5HTfGQE7h 4 | Mail Missions: 5 | fields: 6 | - ID 7 | - Notes -------------------------------------------------------------------------------- /src/v0.2/auth/public.yml: -------------------------------------------------------------------------------- 1 | --- 2 | YOUR_AIRTABLE_NAME: 3 | baseID: YOUR_AIRTABLE_BASE_ID 4 | YOUR_BASE: 5 | - ALLOWLISTED_FIELD_1 6 | - ALLOWLISTED_FIELD_2 7 | - ALLOWLISTED_FIELD_3 8 | Operations: 9 | baseID: apptEEFG5HTfGQE7h 10 | Badges: 11 | fields: 12 | - ID 13 | - Name 14 | - Emoji Tag 15 | - Icon 16 | - People Slack IDs -------------------------------------------------------------------------------- /src/v0.2/auth/slash-z-msw-dev.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Slash-z-msw-dev: 3 | baseID: appuEsdMf6hHXibSh 4 | Hosts: 5 | fields: 6 | - Name Displayed to Users 7 | - API Key 8 | - API Secret 9 | - Zoom ID 10 | -------------------------------------------------------------------------------- /src/v0.2/auth/sprig-order.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Sprig Waitlist: 3 | baseID: appN9cSnDgORAM2bp 4 | Authentication: 5 | post: 6 | - GitHub Username 7 | - Oauth State 8 | - GitHub Token -------------------------------------------------------------------------------- /src/v0.2/auth/template.yml: -------------------------------------------------------------------------------- 1 | # This template is for 2 | --- 3 | YOUR_AIRTABLE_NAME: 4 | baseID: YOUR_AIRTABLE_BASE_ID 5 | YOUR_BASE: 6 | - ALLOWLISTED_FIELD_1 7 | - ALLOWLISTED_FIELD_2 8 | - ALLOWLISTED_FIELD_3 -------------------------------------------------------------------------------- /src/v0.2/auth/test.yml: -------------------------------------------------------------------------------- 1 | # This list's token is publicly available for testing authenticated requests on purpose. You should normally never give out this token publicly. 2 | # recx3vr3ziWHPc3K0161186195268u9j3l4z9e 3 | --- 4 | Airbridge: 5 | baseID: appP3uDe6tFt7cA5r 6 | Test - All Fields: 7 | readAllFields: true 8 | Test - Some Fields: 9 | fields: 10 | - You should have access to this field 11 | # - "You should not have access to this field" 12 | Test - Record ID Only: 13 | # If a table has no permissions data, record ID is implicity allowed 14 | -------------------------------------------------------------------------------- /src/v0.2/index.js: -------------------------------------------------------------------------------- 1 | const PUBLIC_AUTH_KEY = "recsxFPWtS57ipww81611873047g41m8dw1t8p" // publicly available authkey to use if user doesn't provide their own. This is intended to be publicly viewable 2 | 3 | import Airtable from "airtable" 4 | import express from "express" 5 | import { getPermissions } from "./permissions" 6 | const router = express.Router() 7 | const env = process.env.NODE_ENV || "development" 8 | 9 | router.use(async (req, res, next) => { 10 | // preprocess all requests 11 | res.locals.start = Date.now() 12 | res.locals.response = {} 13 | res.locals.authKey = req.query.authKey || PUBLIC_AUTH_KEY 14 | res.locals.showMeta = req.query.meta 15 | res.locals.meta = { 16 | version: 0.2, 17 | params: { ...req.params }, 18 | query: { ...req.query }, 19 | body: req.body, 20 | } 21 | 22 | if (req.query.authKey) { 23 | res.locals.authKey = req.query.authKey 24 | res.locals.meta.query.authKey = "[redacted]" 25 | } 26 | 27 | res.locals.permissions = await getPermissions(res.locals.authKey) 28 | 29 | if (!res.locals.permissions) { 30 | const error = new Error("Invalid authKey provided") 31 | error.statusCode = 401 32 | return next(error) 33 | } 34 | 35 | next() 36 | }) 37 | 38 | router.get("/:base/:tableName/:recordID", async (req, res, next) => { 39 | const basePermission = Object.keys(res.locals.permissions).includes( 40 | req.params.base 41 | ) 42 | if (!basePermission) { 43 | const error = new Error("Base not found or permissions insufficient") 44 | error.statusCode = 404 45 | return next(error) 46 | } 47 | 48 | const tablePermission = Object.keys( 49 | res.locals.permissions[req.params.base] 50 | ).includes(req.params.tableName) 51 | if (!tablePermission) { 52 | const error = new Error("Table not found or permissions insufficient") 53 | error.statusCode = 404 54 | return next(error) 55 | } 56 | const permittedFields = 57 | res.locals.permissions[req.params.base][req.params.tableName].get 58 | const unpermittedFields = Object.keys(req.body).filter( 59 | (field) => !permittedFields.includes(field) 60 | ) 61 | const fieldPermission = unpermittedFields.length === 0 62 | if (!fieldPermission) { 63 | const error = new Error( 64 | `Field(s) ${unpermittedFields 65 | .map((f) => `'${f}'`) 66 | .join(", ")} doesn't exist or permissions insufficient` 67 | ) 68 | error.statusCode = 422 69 | return next(error) 70 | } 71 | 72 | // we have permission to use the table, pull the info 73 | const ab = res.locals.permissions[req.params.base].baseID 74 | const at = req.params.tableName 75 | const airinst = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base( 76 | ab 77 | )(at) 78 | let select = {} 79 | if (req.query.select) { 80 | select = JSON.parse(req.query.select) 81 | } 82 | select.filterByFormula = `RECORD_ID()='${req.params.recordID}'` 83 | select.maxRecords = 1 84 | const rawResults = await airinst 85 | .select(select) 86 | .all() 87 | .catch((err) => console.log(err)) 88 | 89 | const permissions = 90 | res.locals.permissions[req.params.base][req.params.tableName]?.get || [] 91 | const results = rawResults.map((rawResult) => { 92 | const filteredResult = { id: rawResult.id, fields: {} } 93 | permissions.forEach((field) => { 94 | filteredResult.fields[field] = rawResult.fields[field] 95 | }) 96 | return filteredResult 97 | }) 98 | 99 | res.locals.response = results[0] 100 | respond(null, req, res, next) 101 | }) 102 | 103 | router.get("/:base/:tableName", async (req, res, next) => { 104 | const basePermission = Object.keys(res.locals.permissions).includes( 105 | req.params.base 106 | ) 107 | if (!basePermission) { 108 | const error = new Error("Base not found or permissions insufficient") 109 | error.statusCode = 404 110 | return next(error) 111 | } 112 | 113 | const tablePermission = Object.keys( 114 | res.locals.permissions[req.params.base] 115 | ).includes(req.params.tableName) 116 | if (!tablePermission) { 117 | const error = new Error("Table not found or permissions insufficient") 118 | error.statusCode = 404 119 | return next(error) 120 | } 121 | const permittedFields = 122 | res.locals.permissions[req.params.base][req.params.tableName].get 123 | const unpermittedFields = Object.keys(req.body).filter( 124 | (field) => !permittedFields.includes(field) 125 | ) 126 | const fieldPermission = unpermittedFields.length === 0 127 | if (!fieldPermission) { 128 | const error = new Error( 129 | `Field(s) ${unpermittedFields 130 | .map((f) => `'${f}'`) 131 | .join(", ")} doesn't exist or permissions insufficient` 132 | ) 133 | error.statusCode = 422 134 | return next(error) 135 | } 136 | 137 | // we have permission to use the table, pull the info 138 | const ab = res.locals.permissions[req.params.base].baseID 139 | const at = req.params.tableName 140 | const airinst = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base( 141 | ab 142 | )(at) 143 | let select = {} 144 | if (req.query.select) { 145 | select = JSON.parse(req.query.select) 146 | } 147 | const rawResults = await airinst 148 | .select(select) 149 | .all() 150 | .catch((err) => console.log(err)) 151 | 152 | const permissions = 153 | res.locals.permissions[req.params.base][req.params.tableName]?.get || [] 154 | const results = rawResults.map((rawResult) => { 155 | const filteredResult = { id: rawResult.id, fields: {} } 156 | permissions.forEach((field) => { 157 | filteredResult.fields[field] = rawResult.fields[field] 158 | }) 159 | return filteredResult 160 | }) 161 | 162 | res.locals.response = results 163 | respond(null, req, res, next) 164 | }) 165 | 166 | router.post("/:base/:tableName", async (req, res, next) => { 167 | const basePermission = Object.keys(res.locals.permissions).includes( 168 | req.params.base 169 | ) 170 | if (!basePermission) { 171 | const error = new Error("Base not found or permissions insufficient") 172 | error.statusCode = 404 173 | return next(error) 174 | } 175 | const tablePermission = Object.keys( 176 | res.locals.permissions[req.params.base] 177 | ).includes(req.params.tableName) 178 | if (!tablePermission) { 179 | const error = new Error("Table not found or permissions insufficient") 180 | error.statusCode = 404 181 | return next(error) 182 | } 183 | const unpermittedFields = Object.keys(req.body).filter( 184 | (field) => 185 | !res.locals.permissions[req.params.base][ 186 | req.params.tableName 187 | ].post.includes(field) 188 | ) 189 | const fieldPermission = unpermittedFields.length === 0 190 | if (!fieldPermission) { 191 | const error = new Error( 192 | `Field(s) ${unpermittedFields 193 | .map((f) => `'${f}'`) 194 | .join(", ")} doesn't exist or permissions insufficient` 195 | ) 196 | error.statusCode = 422 197 | return next(error) 198 | } 199 | 200 | // we have permission to use the table, pull the info & leave 201 | const ab = res.locals.permissions[req.params.base].baseID 202 | const at = req.params.tableName 203 | const airinst = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base( 204 | ab 205 | )(at) 206 | const createValues = Array.isArray(req.body) 207 | ? { fields: req.body } 208 | : [{ fields: req.body }] 209 | const rawResults = await airinst 210 | .create(createValues) 211 | .catch((err) => console.log(err)) 212 | 213 | const rawResultArray = Array.isArray(rawResults) ? rawResults : [rawResults] 214 | const results = rawResults.map((rec) => { 215 | const result = { id: rec.id, fields: {} } 216 | const readPermissions = 217 | res.locals.permissions[req.params.base][req.params.tableName]?.get || [] 218 | const writePermissions = 219 | res.locals.permissions[req.params.base][req.params.tableName].post 220 | 221 | const allowedFields = Array.from([ 222 | ...new Set([...readPermissions, ...writePermissions]), 223 | ]) 224 | allowedFields.forEach((field) => { 225 | result.fields[field] = rec.fields[field] 226 | }) 227 | return result 228 | }) 229 | if (Array.isArray(req.body)) { 230 | res.locals.response = results 231 | } else { 232 | res.locals.response = results[0] 233 | } 234 | 235 | respond(null, req, res, next) 236 | }) 237 | 238 | router.patch("/:base/:tableName", async (req, res, next) => { 239 | res.status(501).send("v0.2 PATCH not implemented yet!") 240 | }) 241 | 242 | router.get("/test", async (req, res, next) => { 243 | res.json({ ping: "pong" }) 244 | next() 245 | }) 246 | 247 | router.use((error, req, res, next) => { 248 | res.status(error.statusCode || 500).json({ error: error.toString() }) 249 | }) 250 | 251 | function respond(err, req, res, next) { 252 | res.locals.meta.duration = Date.now() - res.locals.start 253 | res.locals.meta.params = { 254 | ...res.locals.meta.params, 255 | ...req.params, 256 | version: 0.2, 257 | } 258 | 259 | if (err) { 260 | const statusCode = err.statusCode || 500 261 | res.status(statusCode).send({ 262 | error: { ...err, message: err.message, statusCode }, 263 | meta: res.locals.meta, 264 | }) 265 | } else { 266 | if (res.locals.showMeta) { 267 | if (Array.isArray(res.locals.response)) { 268 | res.locals.meta.resultCount = res.locals.response.length 269 | } else { 270 | res.locals.meta.resultCount = 1 271 | } 272 | res.json({ 273 | response: res.locals.response, 274 | meta: res.locals.meta, 275 | }) 276 | } else { 277 | res.json(res.locals.response) 278 | } 279 | } 280 | 281 | next() 282 | } 283 | 284 | export default router 285 | -------------------------------------------------------------------------------- /src/v0.2/permissions.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch" 2 | import fs from "fs" 3 | import path from "path" 4 | import yaml from "js-yaml" 5 | 6 | export async function getPermissions(authId) { 7 | const opts = { 8 | maxRecords: 1, 9 | filterByFormula: `Authtoken='${authId}'`, 10 | } 11 | 12 | const records = await ( 13 | await fetch( 14 | "https://airbridge.hackclub.com/v0.1/Airbridge/Authtokens?select=" + 15 | JSON.stringify(opts) 16 | ) 17 | ).json() 18 | const record = records[0] 19 | if (!record) { 20 | return null 21 | } 22 | const filename = record.fields["List File Name"] 23 | const file = fs.readFileSync( 24 | path.resolve(__dirname, `./auth/${filename}`), 25 | "utf8" 26 | ) 27 | const doc = yaml.load(file) 28 | return doc 29 | } 30 | -------------------------------------------------------------------------------- /src/v0/README.md: -------------------------------------------------------------------------------- 1 | ![](api2.jpg) 2 | 3 | **Basic calls** 4 | ```sh 5 | # Ensure you can connect 6 | curl https://api2.hackclub.com/ping 7 | # returns "pong!" 8 | 9 | # Get all records in a table 10 | curl https://api2.hackclub.com/v0/Operations/Badges 11 | # returns JSON array of records from 'Badges' table in 'Operations' base 12 | ``` 13 | 14 | **Authorization** 15 | ```sh 16 | # Getting a protected route w/o using an auth token 17 | curl https://api2.hackclub.com/v0/Operations/Addresses # or some other sensative data 18 | # "ERROR: Tried to access table that is not in the allowlist." 19 | 20 | # Lend your own Airtable API keys to get past the allowlist 21 | curl -H "Authorization: Bearer VALID_AIRTABLE_KEY" https://api2.hackclub.com/v0/Operations/Addresses 22 | # returns JSON array of non-allowlisted records from airtable 23 | ``` 24 | 25 | **Filtering, searching** 26 | ```sh 27 | # The "select" URL param can be used to pass arguments into Airtable's API. A list of optional parameters are show below with examples 28 | 29 | # Get only 1 record 30 | curl https://api2.hackclub.com/v0/Operations/Badges?select={"maxResults":1} 31 | 32 | # Get all badges, sorted by the number of people who have earned them 33 | curl https://api2.hackclub.com/v0/Operations/Badges?select={"sort":[{"field":"People Count","direction":"desc"}]} 34 | 35 | # You can combine arguments too. Here we'll sort badges by the number of people 36 | # and select the single record at the top of the list to get the badge with the 37 | # most users 38 | curl https://api2.hackclub.com/v0/Operations/Badges?select={"sort":[{"field":"People Count","direction":"desc"}],"maxResults":1} 39 | 40 | # You can select specific fields to return to speed up queries/only see the data you want: 41 | curl https://api2.hackclub.com/v0/Operations/Badges?select={"fields":["Name"]} 42 | ``` 43 | 44 | ## Developing & Contributing 45 | 46 | ```sh 47 | git clone https://github.com/hackclub/api2 48 | cd api2/ 49 | pnpm install 50 | 51 | # Run locally with nodemon 52 | pnpm run dev 53 | # Run tests 54 | pnpm test 55 | ``` 56 | 57 | ### Development tools 58 | 59 | ```sh 60 | # The `meta` url param will return a JSON object with metadata about the request you've just made 61 | curl http://localhost:5000/v0/Operations/Clubs/?meta=true 62 | # returns the following: 63 | { 64 | result: [...], # Array of badges from Airtable 65 | meta: { 66 | duration: 241, # Time in ms to complete request 67 | query: {...}, # List of URL query params parsed by the server 68 | params: {...}, # List of URL params parsed by the server 69 | } 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /src/v0/allowlist.js: -------------------------------------------------------------------------------- 1 | import { lookupBaseID } from "./utils" 2 | 3 | export const baseInfo = { 4 | Operations: "apptEEFG5HTfGQE7h", 5 | "CCC Newsfeed": "appQF79M2Gp8cfKR0", 6 | "hackathons.hackclub.com": "apptapPDAi0eBaaG1", 7 | "SDP Priority Activations": "apple9fiV81JsRytC", 8 | "Command Center Schedule": "appGvXhgsuXhCTrOr", 9 | Sessions: "appezi7TOQFt8vTfa", 10 | "Draw in the dark": "applcpliMnombEJb9", 11 | "Bank Promotions": "appEzv7w2IBMoxxHe", 12 | "hack.af": "app34oF1jf9o0AQHp", 13 | CDN: "appnjnz8RYdlkJfIu", 14 | } 15 | 16 | const allowlistInfo = { 17 | "SDP Priority Activations": { 18 | "SDP Priority Activations": [ 19 | "Submission Time", 20 | "Approved", 21 | "Rejection Reason", 22 | "Source type", 23 | "Mail Mission", 24 | "Address (city)", 25 | "Address (state)", 26 | "Address (zip code)", 27 | ], 28 | }, 29 | "CCC Newsfeed": { 30 | Items: ["Title", "Subtitle", "Notes", "Autonumber", "Attachments"], 31 | }, 32 | "hackathons.hackclub.com": { 33 | applications: [ 34 | "name", 35 | "website", 36 | "start", 37 | "end", 38 | "parsed_city", 39 | "parsed_state_code", 40 | "parsed_country", 41 | "parsed_country_code", 42 | "hackclub_affiliated", 43 | "mlh_associated", 44 | "logo", 45 | "banner", 46 | "approved", 47 | "virtual", 48 | "id", 49 | "created_at", 50 | "lat", 51 | "lng", 52 | ], 53 | }, 54 | Operations: { 55 | Clubs: [ 56 | "Name", 57 | "Slack Channel ID", 58 | "Leader Slack IDs", 59 | "Address City", 60 | "Address State", 61 | "Address Postal Code", 62 | "Address Country", 63 | "Club URL", 64 | "Latitude", 65 | "Longitude", 66 | ], 67 | Badges: ["ID", "Name", "Emoji Tag", "Icon", "People Slack IDs"], 68 | }, 69 | "Command Center Schedule": { 70 | Schedule: [ 71 | "Session Name", 72 | "Leader Name", 73 | "Time (Eastern)", 74 | "Date (formatted)", 75 | "Time (formatted)", 76 | "Calendar Event", 77 | "Preview Image", 78 | ], 79 | }, 80 | Sessions: { 81 | Events: [ 82 | "Title", 83 | "Description", 84 | "Leader", 85 | "Start Time", 86 | "End Time", 87 | "Avatar", 88 | "Emoji", 89 | "AMA", 90 | "AMA Name", 91 | "AMA Company", 92 | "AMA Title", 93 | "AMA Link", 94 | "AMA Id", 95 | "AMA Avatar", 96 | "Calendar Link", 97 | ], 98 | }, 99 | "Draw in the dark": { 100 | Deadline: ["Time", "Preview", "Art title", "Active"], 101 | }, 102 | "Bank Promotions": { 103 | "Github Grant": ["Status"], 104 | }, 105 | } 106 | 107 | export function allowlistBaseTable(baseID, tableName) { 108 | const allowlistedBase = Object.keys(allowlistInfo).find( 109 | (key) => lookupBaseID(key) === lookupBaseID(baseID) 110 | ) 111 | if (!allowlistedBase) { 112 | const err = new Error( 113 | "Not found: base either doesn't exist or isn't publicly accessible" 114 | ) 115 | err.statusCode = 404 116 | throw err 117 | } else { 118 | console.log("Publicly accessing base", baseID) 119 | } 120 | 121 | const allowlistedTable = allowlistInfo[allowlistedBase][tableName] 122 | if (!allowlistedTable) { 123 | const err = new Error( 124 | "Not found: table either doesn't exist or isn't publicly accessible" 125 | ) 126 | err.statusCode = 404 127 | throw err 128 | } else { 129 | console.log("Publicly accessing table", tableName) 130 | } 131 | return allowlistedTable 132 | } 133 | 134 | export function allowlistRecords(records, allowlistedFields) { 135 | if (Array.isArray(records)) { 136 | return records.map((record) => allowlistRecords(record, allowlistedFields)) 137 | } else { 138 | const record = records 139 | const result = { 140 | id: record.id, 141 | fields: {}, 142 | } 143 | 144 | allowlistedFields.forEach( 145 | (field) => (result.fields[field] = record.fields[field]) 146 | ) 147 | return result 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/v0/api2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackclub/airbridge/cbc36bdfc59121943c64c5f0135bfb020d13401b/src/v0/api2.jpg -------------------------------------------------------------------------------- /src/v0/index.js: -------------------------------------------------------------------------------- 1 | import express from "express" 2 | const router = express.Router() 3 | const env = process.env.NODE_ENV || "development" 4 | import { airtableLookup } from "./utils" 5 | 6 | router.get("/:base/:tableName/:recordID", async (req, res, next) => { 7 | /* 8 | version: Required. api version to use. Before version 0.1 this isn't being checked– go ahead and put a 0 there 9 | base: Required. Either base ID ("routertEEFG5HTfGQE7h") or base name ("Operations") 10 | tableName: Required. WARNING: this field doesn't get read due to a bug in Airtable they've committed to not patching 11 | RecordID: Required. ex "Clubs" 12 | */ 13 | const startTime = Date.now() 14 | const meta = { 15 | params: { ...req.params, version: 0 }, 16 | query: { ...req.query }, 17 | } 18 | if (req.query.authKey) { 19 | meta.query.authKey = "[redacted]" 20 | } 21 | try { 22 | let providedAuth 23 | if (req.headers.authorization) { 24 | // example auth header "Bearer key9uu912ij9e" 25 | providedAuth = req.headers.authorization.replace("Bearer ", "") 26 | } 27 | if (env === "development" || env === "test") { 28 | providedAuth = req.query.authKey 29 | } 30 | const options = { 31 | base: req.params.base, 32 | tableName: req.params.tableName, 33 | recordID: req.params.recordID, 34 | authKey: providedAuth, 35 | } 36 | if (req.query.select) { 37 | options.select = JSON.parse(req.query.select) 38 | } 39 | const result = await airtableFind(options, providedAuth) 40 | 41 | meta.duration = Date.now() - startTime 42 | 43 | if (req.query.meta) { 44 | res.json({ result, meta }) 45 | } else { 46 | res.json(result) 47 | } 48 | } catch (err) { 49 | console.log(err.message) 50 | 51 | const statusCode = err.statusCode || 500 52 | meta.duration = Date.now() - startTime 53 | res.status(statusCode).send({ 54 | error: { 55 | message: err.message, 56 | statusCode, 57 | }, 58 | meta, 59 | }) 60 | } 61 | }) 62 | router.get("/:base/:tableName", async (req, res, next) => { 63 | /* 64 | version: Required. api version to use. Before version 1.0 this isn't being checked– go ahead and put a 0 there 65 | base: Required. Either base ID ("routertEEFG5HTfGQE7h") or base name ("Operations") 66 | tableName: Required. ex "Clubs" 67 | */ 68 | const startTime = Date.now() 69 | const meta = { 70 | params: { ...req.params, version: 0 }, 71 | query: { ...req.query }, 72 | } 73 | if (req.query.authKey) { 74 | meta.query.authKey = "[redacted]" 75 | } 76 | try { 77 | let providedAuth 78 | if (req.headers.authorization) { 79 | // example auth header "Bearer key9uu912ij9e" 80 | providedAuth = req.headers.authorization.replace("Bearer ", "") 81 | } 82 | if (env === "development" || env === "test") { 83 | providedAuth = req.query.authKey 84 | } 85 | const options = { 86 | base: req.params.base, 87 | tableName: req.params.tableName, 88 | authKey: providedAuth, 89 | } 90 | if (req.query.select) { 91 | options.select = JSON.parse(req.query.select) 92 | } 93 | const result = await airtableLookup(options, providedAuth) 94 | 95 | meta.duration = Date.now() - startTime 96 | 97 | if (req.query.meta) { 98 | res.json({ result, meta }) 99 | } else { 100 | res.json(result) 101 | } 102 | } catch (err) { 103 | console.log(err.message) 104 | 105 | const statusCode = err.statusCode || 500 106 | meta.duration = Date.now() - startTime 107 | res.status(statusCode).send({ 108 | error: { 109 | message: err.message, 110 | statusCode, 111 | }, 112 | meta, 113 | }) 114 | } 115 | }) 116 | 117 | export default router 118 | -------------------------------------------------------------------------------- /src/v0/utils.js: -------------------------------------------------------------------------------- 1 | import Airtable from "airtable" 2 | import { allowlistBaseTable, allowlistRecords, baseInfo } from "./allowlist" 3 | 4 | export function lookupBaseID(baseID) { 5 | const lookedUpID = baseInfo[baseID] 6 | return lookedUpID || baseID 7 | } 8 | 9 | export async function airtableLookup(options, auth) { 10 | const { base, tableName, select } = options 11 | const baseID = lookupBaseID(base) 12 | 13 | if (auth) { 14 | const airinst = new Airtable({ apiKey: auth }).base(baseID)(tableName) 15 | const rawResults = await airinst.select(select).all() 16 | return rawResults.map((result) => ({ 17 | id: result.id, 18 | fields: result.fields, 19 | })) 20 | } else { 21 | const allowlistedFields = allowlistBaseTable(baseID, tableName, auth) 22 | 23 | let resultFields = [] 24 | if (select && Array.isArray(select.fields)) { 25 | resultFields = allowlistedFields.filter((f) => select.fields.includes(f)) 26 | } else { 27 | resultFields = allowlistedFields 28 | } 29 | 30 | const airinst = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base( 31 | baseID 32 | )(tableName) 33 | 34 | const rawResults = await airinst 35 | .select({ ...select, fields: resultFields }) 36 | .all() 37 | 38 | return allowlistRecords(rawResults, resultFields) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Tests are broken into 2 categories: 4 | 5 | _Production tests_ are tests that require production API keys to be loaded. They can be triggered with `npm run production-test`. 6 | 7 | _Basic tests_ are tests that can be run anytime. They can be triggered with `npm run basic-test`. 8 | 9 | You can run all tests using `npm run test` -------------------------------------------------------------------------------- /tests/general-routes.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest') 2 | let app 3 | 4 | beforeAll(() => { 5 | app = require("../src/index").server 6 | return app 7 | }) 8 | 9 | describe('GET / (basic)', () => { 10 | it('responds with a redirect', async () => { 11 | const res = await request(app).get('/') 12 | expect(res.statusCode).toEqual(302) 13 | }) 14 | }) 15 | 16 | describe('GET /ping (basic)', () => { 17 | it('responds with a success', async () => { 18 | const res = await request(app).get('/ping') 19 | expect(res.statusCode).toEqual(200) 20 | }) 21 | it('responds with a json response', async () => { 22 | const res = await request(app).get('/ping') 23 | expect(res.body).toBeDefined() 24 | expect(res.body.message).toEqual('pong!') 25 | }) 26 | }) 27 | 28 | describe('GET /Operations/Badges (missing version number) (basic)', () => { 29 | it('responds with Not Found', async () => { 30 | const res = await request(app).get('/Operations/Badges') 31 | expect(res.statusCode).toEqual(404) 32 | }) 33 | }) 34 | 35 | describe('GET /v9999/Operations/Badges (invalid version number) (basic)', () => { 36 | it('responds with Not Found', async () => { 37 | const res = await request(app).get('/v9999/Operations/Badges') 38 | expect(res.statusCode).toEqual(404) 39 | }) 40 | }) 41 | 42 | afterAll(() => app?.close()) -------------------------------------------------------------------------------- /tests/sample.test.js: -------------------------------------------------------------------------------- 1 | describe('Sample Test (basic)', () => { 2 | it('should test that true === true', () => { 3 | expect(true).toBe(true) 4 | }) 5 | }) -------------------------------------------------------------------------------- /tests/v0.1/allowlist.test.js: -------------------------------------------------------------------------------- 1 | const yaml = require("js-yaml") 2 | const fs = require("fs") 3 | const path = require("path") 4 | const request = require("supertest") 5 | let app 6 | 7 | beforeAll(() => { 8 | app = require("../../src/index").server 9 | return app 10 | }) 11 | 12 | const allowlistPath = "../../src/v0.1/airtable-info.yml" 13 | 14 | describe("load allowlist info (basic)", () => { 15 | it("is in a file", () => { 16 | const file = fs.readFileSync(path.resolve(__dirname, allowlistPath), "utf8") 17 | expect(file).toBeDefined() 18 | }) 19 | 20 | it("is a parsable yaml file", () => { 21 | const data = yaml.load( 22 | fs.readFileSync(path.resolve(__dirname, allowlistPath), "utf8") 23 | ) 24 | 25 | expect(data["YOUR_AIRTABLE_NAME"]).toBeDefined() 26 | expect(data["YOUR_AIRTABLE_NAME"]["baseID"]).toMatch( 27 | "YOUR_AIRTABLE_BASE_ID" 28 | ) 29 | }) 30 | }) 31 | 32 | describe("GET allowlisted routes (production)", () => { 33 | jest.setTimeout(30000) 34 | 35 | const routes = [] 36 | const tables = yaml.load( 37 | fs.readFileSync(path.resolve(__dirname, allowlistPath), "utf8") 38 | ) 39 | Object.keys(tables).forEach((tableN) => { 40 | const bases = tables[tableN] 41 | Object.keys(bases).forEach((baseN) => { 42 | if (tableN != "YOUR_AIRTABLE_NAME" && baseN != "baseID") { 43 | routes.push({ base: baseN, table: tableN }) 44 | } 45 | }) 46 | }) 47 | 48 | routes.forEach((route) => { 49 | const endpointBase = `/v0.1/${route.table}/${route.base}` 50 | const options = { maxRecords: 1 } 51 | const endpoint = `${endpointBase}?meta=true&select=${JSON.stringify( 52 | options 53 | )}` 54 | it(`loads ${endpointBase} with successful status code`, async (done) => { 55 | const res = await request(app).get(endpoint) 56 | expect(res.statusCode).toEqual(200) 57 | done() 58 | }) 59 | }) 60 | }) 61 | 62 | afterAll(() => app.close()) 63 | -------------------------------------------------------------------------------- /tests/v0.1/cache.test.js: -------------------------------------------------------------------------------- 1 | const request = require("supertest") 2 | let app 3 | 4 | beforeAll(() => { 5 | app = require("../../src/index").server 6 | return app 7 | }) 8 | 9 | describe("GET request with cache enabled (production)", () => { 10 | const route = "/v0.1/Operations/Badges?cache=true&meta=true" 11 | 12 | it("increments the cache hit", async () => { 13 | await request(app).get(route) 14 | const res = await request(app).get(route) 15 | expect(res.body.meta.cache.keys).toBeGreaterThan(0) 16 | }) 17 | it("indicates the data was pulled from cache", async () => { 18 | await request(app).get(route) 19 | const res = await request(app).get(route) 20 | expect(res.body.meta.cache.pulledFrom).toEqual(true) 21 | }) 22 | it("runs faster than without cache", async () => { 23 | const routeNoCache = "/v0.1/Operations/Badges?meta=true" 24 | const resNoCache = await request(app).get(routeNoCache) 25 | const res = await request(app).get(route) 26 | expect(resNoCache.body.meta.duration).toBeGreaterThan( 27 | res.body.meta.duration 28 | ) 29 | }) 30 | }) 31 | 32 | afterAll(() => app.close()) 33 | -------------------------------------------------------------------------------- /tests/v0.1/routes.test.js: -------------------------------------------------------------------------------- 1 | const request = require("supertest") 2 | let app 3 | 4 | beforeAll(() => { 5 | app = require("../../src/index").server 6 | return app 7 | }) 8 | 9 | describe("GET /v0.1/Cake/Badges (invalid base) (production)", () => { 10 | it("responds with Not Found", async () => { 11 | const res = await request(app).get("/v0.1/Cake/Badges") 12 | expect(res.statusCode).toEqual(404) 13 | }) 14 | it("responds with json error", async () => { 15 | const res = await request(app).get("/v0.1/Cake/Badges") 16 | expect(res.body).toBeDefined() 17 | expect(res.body.error).toBeDefined() 18 | expect(res.body.error.message).toMatch("Not found") 19 | }) 20 | }) 21 | 22 | describe("GET /v0.1/Operations/Cake (invalid table) (production)", () => { 23 | it("responds with Not Found", async () => { 24 | const res = await request(app).get("/v0.1/Operations/Cake") 25 | expect(res.statusCode).toEqual(404) 26 | }) 27 | it("responds with json error", async () => { 28 | const res = await request(app).get("/v0.1/Operations/Cake") 29 | expect(res.body).toBeDefined() 30 | expect(res.body.error).toBeDefined() 31 | expect(res.body.error.message).toMatch("Not found") 32 | }) 33 | }) 34 | 35 | describe("GET /v0.1/Operations/Badges (production)", () => { 36 | it("responds with successful status code", async () => { 37 | const res = await request(app).get("/v0.1/Operations/Badges") 38 | expect(res.statusCode).toEqual(200) 39 | }) 40 | it("responds with an array of airtable records", async () => { 41 | const res = await request(app).get("/v0.1/Operations/Badges") 42 | expect(res.body).toBeDefined() 43 | expect(Array.isArray(res.body)).toEqual(true) 44 | }) 45 | }) 46 | 47 | describe("POST /v0.1/Operations/Badges (without auth) (production)", () => { 48 | it("responds with Unauthorized", async () => { 49 | const res = await request(app).post("/v0.1/Operations/Badges") 50 | expect(res.statusCode).toEqual(401) 51 | }) 52 | }) 53 | 54 | describe("POST /v0.1/Operations/Badges (invalid auth) (production)", () => { 55 | it("responds with Unauthorized", async () => { 56 | const res = await request(app).post( 57 | "/v0.1/Operations/Badges?authKey=123456" 58 | ) 59 | expect(res.statusCode).toEqual(401) 60 | }) 61 | }) 62 | 63 | describe("PATCH /v0.1/Operations/Badges (without auth) (production)", () => { 64 | it("responds with Unauthorized", async () => { 65 | const res = await request(app).patch("/v0.1/Operations/Badges") 66 | expect(res.statusCode).toEqual(401) 67 | }) 68 | }) 69 | 70 | describe("PATCH /v0.1/Operations/Badges (without body) (production)", () => { 71 | it("responds with Unprocessable", async () => { 72 | const res = await request(app).patch( 73 | "/v0.1/Operations/Badges?authKey=123456" 74 | ) 75 | expect(res.statusCode).toEqual(422) 76 | }) 77 | }) 78 | 79 | afterAll(() => app.close()) 80 | -------------------------------------------------------------------------------- /tests/v0.2/auth-files.test.js: -------------------------------------------------------------------------------- 1 | const yaml = require("js-yaml") 2 | const fs = require("fs") 3 | const path = require("path") 4 | const request = require("supertest") 5 | // const app = require("../../src/index").server 6 | 7 | // const publicAuthList = "../../src/v0.2/auth/public.yml" 8 | // const testAllowList = "../../src/v0.2/auth/test.yml" 9 | 10 | const authDirectory = path.resolve(__dirname, "../../src/v0.2/auth/") 11 | 12 | const cases = fs.readdirSync(path.resolve(__dirname, authDirectory)) 13 | 14 | describe("auth lists load & are valid yml (basic)", () => { 15 | test.each(cases)("%s loads", (fileName) => { 16 | const file = fs.readFileSync( 17 | path.resolve(__dirname, authDirectory, fileName), 18 | "utf-8" 19 | ) 20 | expect(file).toBeDefined() 21 | }) 22 | 23 | test.each(cases)("%s is valid YAML", (fileName) => { 24 | const file = fs.readFileSync( 25 | path.resolve(__dirname, authDirectory, fileName), 26 | "utf-8" 27 | ) 28 | expect(() => yaml.load(file)).not.toThrow() 29 | }) 30 | 31 | test.each(cases)("%s is valid YAML", (fileName) => { 32 | const file = fs.readFileSync( 33 | path.resolve(__dirname, authDirectory, fileName), 34 | "utf-8" 35 | ) 36 | expect(() => yaml.load(file)).not.toThrow() 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /tests/v0.2/auth-test-file.test._js: -------------------------------------------------------------------------------- 1 | const yaml = require("js-yaml") 2 | const fs = require("fs") 3 | const path = require("path") 4 | const request = require("supertest") 5 | const app = require("../../src/index").server 6 | 7 | const authDirectory = "../../src/v0.2/auth/" 8 | const testFile = "test.yml" 9 | const TEST_AUTHKEY = "recx3vr3ziWHPc3K0161186195268u9j3l4z9e" 10 | 11 | describe("test auth file", () => { 12 | it("is in a file", () => { 13 | const file = fs.readFileSync( 14 | path.resolve(__dirname, authDirectory, testFile), 15 | "utf8" 16 | ) 17 | expect(file).toBeDefined() 18 | }) 19 | 20 | it("is a parsable yaml file", () => { 21 | const data = yaml.load( 22 | fs.readFileSync(path.resolve(__dirname, authDirectory, testFile), "utf8") 23 | ) 24 | 25 | expect(data["Airbridge"]).toBeDefined() 26 | expect(data["Airbridge"]["baseID"]).toBeDefined() 27 | expect(data["Airbridge"]["baseID"]).toMatch("appP3uDe6tFt7cA5r") 28 | }) 29 | }) 30 | 31 | describe("unauthenticated GET v0.2/Airbridge/Tests", () => { 32 | it("returns 404 not found", async () => { 33 | const res = await request(app).get("/v0.2/Airbridge/Tests") 34 | expect(res.statusCode).toEqual(404) 35 | }) 36 | }) 37 | 38 | describe("invalid token GET v0.2/Airbridge/Tests", () => { 39 | it("returns 401 unauthorized error", async () => { 40 | const res = await request(app).get( 41 | "/v0.2/Airbridge/Tests?authKey=INVALID_AUTH_KEY" 42 | ) 43 | expect(res.statusCode).toEqual(401) 44 | }) 45 | }) 46 | 47 | describe("GET v0.2/Airbridge/Test - All Fields", () => { 48 | it("provides access to all fields", async () => { 49 | const res = await request(app).get( 50 | "/v0.2/Airbridge/Test - All Fields?authKey=" + TEST_AUTHKEY 51 | ) 52 | expect(res.statusCode).toEqual(200) 53 | expect(res.body).toBeDefined() 54 | expect(Array.isArray(res.body)).toEqual(true) 55 | expect(res.body[0].fields["Name"]).toBeDefined() 56 | expect(res.body[0].fields["Notes"]).toBeDefined() 57 | expect(res.body[0].fields["Attachments"]).toBeDefined() 58 | }) 59 | }) 60 | 61 | describe("GET v0.2/Airbridge/Test - Record ID Only", () => { 62 | it("provides access to some fields", async () => { 63 | const res = await request(app).get( 64 | "/v0.2/Airbridge/Test - Record ID Only?authKey=" + TEST_AUTHKEY 65 | ) 66 | expect(res.statusCode).toEqual(200) 67 | expect(res.body).toBeDefined() 68 | expect(Array.isArray(res.body)).toEqual(true) 69 | expect(res.body[0].id).toBeDefined() 70 | expect( 71 | res.body[0].fields["This field should not be visible"] 72 | ).not.toBeDefined() 73 | }) 74 | }) 75 | 76 | describe("GET v0.2/Airbridge/Test - Some Fields", () => { 77 | it("provides access to RECORD IDs", async () => { 78 | const res = await request(app).get( 79 | "/v0.2/Airbridge/Test - Some Fields?authKey=" + TEST_AUTHKEY 80 | ) 81 | expect(res.statusCode).toEqual(200) 82 | expect(res.body).toBeDefined() 83 | expect(Array.isArray(res.body)).toEqual(true) 84 | expect(res.body[0].id).toBeDefined() 85 | expect( 86 | res.body[0].fields["You should have access to this field"] 87 | ).toBeDefined() 88 | expect( 89 | res.body[0].fields["You should not have access to this field"] 90 | ).not.toBeDefined() 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /tests/v0.2/routes.test._js: -------------------------------------------------------------------------------- 1 | const request = require("supertest") 2 | const app = require("../../src/index").server 3 | 4 | describe("GET /v0.2/test", () => { 5 | it("responds with successful status code", async () => { 6 | const res = await request(app).get("/v0.2/test") 7 | expect(res.statusCode).toEqual(200) 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /tests/v0/routes.test.js: -------------------------------------------------------------------------------- 1 | const request = require("supertest") 2 | let app 3 | 4 | beforeAll(() => { 5 | app = require("../../src/index").server 6 | return app 7 | }) 8 | 9 | describe("GET /v0/Cake/Badges (invalid base) (production)", () => { 10 | it("responds with Not Found", async () => { 11 | const res = await request(app).get("/v0/Cake/Badges") 12 | expect(res.statusCode).toEqual(404) 13 | }) 14 | it("responds with json error", async () => { 15 | const res = await request(app).get("/v0/Cake/Badges") 16 | expect(res.body).toBeDefined() 17 | expect(res.body.error).toBeDefined() 18 | expect(res.body.error.message).toMatch("Not found") 19 | }) 20 | }) 21 | 22 | describe("GET /v0/Operations/Cake (invalid table) (production)", () => { 23 | it("responds with Not Found", async () => { 24 | const res = await request(app).get("/v0/Operations/Cake") 25 | expect(res.statusCode).toEqual(404) 26 | }) 27 | it("responds with json error", async () => { 28 | const res = await request(app).get("/v0/Operations/Cake") 29 | expect(res.body).toBeDefined() 30 | expect(res.body.error).toBeDefined() 31 | expect(res.body.error.message).toMatch("Not found") 32 | }) 33 | }) 34 | 35 | describe("GET /v0/Operations/Badges (production)", () => { 36 | it("responds with successful status code", async () => { 37 | const res = await request(app).get("/v0/Operations/Badges") 38 | expect(res.statusCode).toEqual(200) 39 | }) 40 | it("responds with an array of airtable records", async () => { 41 | const res = await request(app).get("/v0/Operations/Badges") 42 | expect(res.body).toBeDefined() 43 | expect(Array.isArray(res.body)).toEqual(true) 44 | }) 45 | }) 46 | 47 | afterAll(() => app.close()) 48 | --------------------------------------------------------------------------------