├── .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 |
6 |
7 |
8 | [](https://github.com/hackclub/airbridge/actions/workflows/test.yml)
9 |
10 | [](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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------