Cool Corridors API
Welcome to the Cool Corridors API!
This project aims to inform NYC communities of Air Quality in their neighborhoods using our very own IAQI score!
Links
RapidAPI
If you are a RapidAPI user, you can use the following url to access the API: https://coolcorridors.p.rapidapi.com.
RapidAPI provides a gateway url that allows users to acces the API from within RapidAPI's website.
GitHub
API
The API is hosted by render.com. The base url is here. All other endpoints are relative to this url. The GitHub link for the API is here.
Folder Structure
.
├── LICENSE
├── README.md
├── requirements.txt
├── run.py
└── src
├── app.py
├── helpers.py
├── __init__.py
├── zip_lat_long.csv
└── zip_lat_long.json
Data
PurpleAir returns their data as a json object formatted as such:
{
// metadata
"fields": [
"name",
"location",
"pm2.5",
"temperature",
...
],
"location_types": [
"outside",
"inside"
],
// data is a list of lists. each list/array in data is the data for a certain sensor
"data": [
[
1284751,
"City College",
56,
...
],
...
]
}
The /score
endpoint will take the above json and format it into the following python dictionary:
{
"zip_code": int,
"pm2.5": float,
"pm2.5_30minute": float,
"pm2.5_60minute": float,
"pm2.5_1week": float,
"temperature": int,
"IAQI": dict{
"score": int,
"category": str
}
}
The frontend will then take this json object and embed data into components in the HTML. Data is subject to change as we add different functionalities.
Endpoints
There are currently 4 endpoints in the API. They are documented here with their expected values and reponse.
All endpoints return the zipcode they were pinged with.
The /data
endpoint returns a json object containing information about temperature, PM2.5 scores, the IAQI score, a description of the score and the color (for frontend visualizations).
The /score
endpoint returns a json object of the IAQI score.
The /color
endpoint returns a json object of the color that we determined for that zipcode.
The /descriptor
endpoint returns a json object of the description text for that zipcode.
GET /data
Parameters
name | type | data type | description |
---|---|---|---|
zipcode | required | int | zipcode of user's location |
api_key | required | string | user's purpleair API key |
Responses
http code | content-type | response |
---|---|---|
200 | application/json | JSON object |
400 | application/json | {error: "Invalid zipcode"} |
400 | application/json | {"error": "No sensors found within the range of "x" miles."} |
400 | application/json | {"error": "No read key or zipcode provided."} |
Example response
{
"zip_code": 11101,
"pm2.5": 2.4,
"pm2.5_30minute": 2.4,
"pm2.5_60minute": 2.6,
"pm2.5_1week": 3.4,
"temperature": 52,
"IAQI": {
"score": 105,
"descriptor": "Good"
}
}
GET /score
Parameters
name | type | data type | description |
---|---|---|---|
zipcode | required | int | zipcode of user's location |
api_key | required | string | user's purpleair API key |
Responses
http code | content-type | response |
---|---|---|
200 | application/json | JSON object |
400 | application/json | {error: "Invalid zipcode"} |
400 | application/json | {"error": "No sensors found within the range of "x" miles."} |
400 | application/json | {"error": "No read key or zipcode provided."} |
Example response
{
"IAQI": 105,
"zipcode": 11101
}
GET /descriptor
Parameters
name | type | data type | description |
---|---|---|---|
zipcode | required | int | zipcode of user's location |
api_key | required | string | user's purpleair API key |
Responses
http code | content-type | response |
---|---|---|
200 | application/json | JSON object |
400 | application/json | {error: "Invalid zipcode"} |
400 | application/json | {"error": "No sensors found within the range of "x" miles."} |
400 | application/json | {"error": "No read key or zipcode provided."} |
Example response
{
"description": "Good",
"zipcode": 11101
}
GET /color
Parameters
name | type | data type | description |
---|---|---|---|
zipcode | required | int | zipcode of user's location |
api_key | required | string | user's purpleair API key |
Responses
http code | content-type | response |
---|---|---|
200 | application/json | JSON object |
400 | application/json | {error: "Invalid zipcode"} |
400 | application/json | {"error": "No sensors found within the range of "x" miles."} |
400 | application/json | {"error": "No read key or zipcode provided."} |
Example response
{
"color": "0xFF00FF",
"zipcode": 11101
}
Client
Short Demo of up to date functionality (click the link if viewing the page through rust's mdbook):
Folder Structure
.
├── README.md
├── requirements.txt
└── src
├── app.py
├── formatdata.py
├── requirements.txt
├── static
│ ├── css
│ │ ├── about.css
│ │ ├── search-results.css
│ │ ├── search.css
│ │ └── style.css
│ └── images
│ └── Logo_IQSpatialLogo.png
└── templates
├── about.jinja
├── layout.jinja
├── search-results.jinja
└── search.jinja
Currently, the layout of the frontend is made up of 4 components within the ./src/template
directory:
layout.jinja
: defines the overall structure of the UI. It is made up of 3 sections (navigation bar, the main content, and a footer).search.jinja
: search bar component. It sends an HTTP post request containing the user input (zipcode) to the/data
endpointsearch-results.jinja
: search results component. This component receives and displays data from the API call on the/data
endpoint.about.jinja
skeleton template set up to populate information on the project and the people behind it.
The CSS file names in ./frontend/static/css
define styling rules to the corresponding Jinja components.
Home Route ("/")
Screenshot:
layout.jinja
: defines the overall structure of the UI. It is made up of 3 sections (navigation bar, the main content, and a footer). This is not whats getting rendered in the app, but rather it is search.jinja
which inherits everything from within layout.jinja
. More on this below.
Here's what the main content of layout.jinja
looks like:
<body>
<header class="nav-bar">
<div>
<a class="link" href="/"><img id = "logo" max-width="120px" max-height ="30px" src=".\..\static\images\Logo_IQSpatialLogo.png"/></a>
<span> <a class="link" href="/about">About</a></span>
</div>
</header>
{# Search form and search results go here #}
<section class="content">
{% block searchbar %} {% endblock %}
{% block about %} {% endblock %}
</section>
<footer>
</footer>
{# Needed for bootstrap components/features that use javascript #}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
crossorigin="anonymous"></script>
</body>
Some important information to keep in mind:
-
The navigation bar is contained within the
<header></header>
tag. In there is the IQS Logo, which can be used to navigate back to this route from any other route, as well as the About text to navigate to the about route. -
The the jinja code within
<section></section>
is what allows this route to be extendable to other routes. Said routes can be specified within the jinja code blocks:<section class="content"> {% block searchbar %} {% endblock %} {% block about %} {% endblock %} </section>
-
search.jinja
is specified as the jinja file to render in this route (the searchbar component within the jinja block) due to the following code inapp.py
:@app.route("/") def home(): # app homepage will default to rendering the search form return render_template("search.jinja")
search.jinja
is able to inherit all the HTML code withinlayout.jinja
due to specifying the following inside it:{% extends "layout.jinja" %} {% endblock %}
the HTML content we'd like to present from this route is then written between the preoceeding two lines.
-
The
<footer></footer>
tag is meant to contain information relating to the project and the people behind it. However, it is empty at the moment. -
The CSS declarations in
static/style.css
is applied on tolayout.jinja
whilestatic/search.css
is applied on tosearch.jinja
on this route.
/search
In this route, API requests are made, and the data recieved gets displayed. Take a look at the following example:
When this route is triggered, it renders search-results.jinja
, which inherits all HTML within search.jinja
(which by extension, also inherits layout.jinja
).
Here's how this is done from within app.py
:
LOCAL_API_BASE_URL = "http://localhost:3000/"
DEPLOYED_API_BASE_URL = "https://cool-corridors-api-service.onrender.com"
@app.route("/search", methods = ["GET", "POST"])
def results():
zipcode = request.form.get("zipcode")
params = {"zipcode": zipcode, "read_key": READ_KEY}
# The API base url used depends on which API instance the developer desire to use
zipcode_data = requests.get(f"{DEPLOYED_API_BASE_URL}/data", params=params).json()
if "error" in zipcode_data:
zipcode_data = json.loads(zipcode_data)
error = True
error_data = zipcode_data["error"]
return render_template("search.jinja", error = error, error_data = error_data)
# use zipcode data and ROW_HEADERS to format the data displayed on the frontend
climate_data = format_zipcode_data(zipcode_data)
return render_template("search-results.jinja", climate_data = climate_data, zipcode = zipcode)
- When the user inputs their zipcode and presses enter,
results()
is triggered and in there, an HTTP GET request to the/data
endpoint of the API is made. This is seen in the following line:
zipcode_data = requests.get(f"{DEPLOYED_API_BASE_URL}/data", params=params).json()
It is worth noting that if you're running the API locally on your machine, then you can replace DEPLOYED_API_BASE_URL
with LOCAL_API_BASE_URL
/about
Note: Due to pivoting to retool for frontend development, this page will no longer being worked on
Screenshot:
When 'about' is clicked on the client, flask triggers a function called about()
in app.py
, which would then render the HTML code in
templates/about.jinja
.
-
app.py
@app.route("/about") def about(): return render_template("about.jinja")
-
templates/about.jinja
:Notice how this page contains the navigation bar and footer due to being an extension of
template.jinja
<link rel="stylesheet" href="{{ url_for('static', filename='css/about.css') }}"> {% extends "layout.jinja" %} {% block about %} <section class="about-body"> <h1> Empty About Page</h1> </section> {% endblock %}
-
The CSS declarations in
static/about.css
are applied on this route
Accessing repositories
The repositories pertainig to this project are all under the github organization. See here for relevant links.
The process for accessing and contributong to these repositories is very straightforward.
-
Go to the repository you wish to contribute to.
-
Copy the repository link
-
In your terminal, execute
git clone <repo_url> cd <repo_name>
As an example, let's clone the repository that holds the markdown files for this website:
-
Get the url from GitHub. Use the above image for assistance.
-
Enter a terminal and git clone.
git clone https://github.com/Cool-Corridors/cool-corridors.github.io.git cd cool-corridors.github.io
You can now make whatever changes you want and then commit back to this repository. Best practice is to create a branch for new features, and then merge them back into the main branch through a pull request. For more information about git, check out their docs or just google it.
Creating a new API endpoint
All endpoints are defined inside ./src/app.py
. To create a new endpoint, simply create a new function and add a new route to the api
object.
-
Navigate to
./src/app.py
. -
Define a new route (name and methods) through the following syntax:
@api.route("/test", methods=[GET,POST]) def test(): return "Hello World!"
The above code will create an endpoint called
test
such that when accessed will show"Hello World!"
.
NOTE
The function name can be anything, but it is recommended to keep it consistent with the endpoint name.
-
Define logic for endpoint.
- Inside the function body, you can access the query string provided through the
request.args.get("<query name>")
- Handle errors, do computations, etc...
Flask offers many different ways to return data. For more information, see the Flask documentation.
You can:
- return a string literal
- return a json object
- render a template (html file)
- redirect to a different route or url
- return a file
- etc...
- Inside the function body, you can access the query string provided through the
Helper Functions
There are a few helper functions that are used throughout the codebase. These functions are defined in /src/helpers.py
.
It is recommended to define new endpoints in /src/app.py
, and define any helper functions in /src/helpers.py
to keep the codebase organized.
Our main helper function is get_info()
. This function takes no input, but returns the same json object that the /data
endpoint returns. Every other endpoints data is just taken from this larger json object.
The function does the following:
-
Obtain
zip_code
andapi_key
Query strings are accessed through the flask
request
object.zipcode = request.args.get("zipcode") read_key = request.args.get("read_key")
If the zipcode or read key is not provided, we return an error message.
-
Convert zipcode into lat/long and create binding box.
To convert a zipcode, we have a lookup table called
ZIP_DICT
. Access it usinglat,lng = ZIP_DICT[zipcode]
Another helper function is already defined to create the binding box. It returns a tuple of the northeast, northwest, southeast, and southwest coordinates of the box.
nwlat,nwlng,selat,selng = get_binding_box(lat,lng,RANGE)
-
Create the url to ping PurpleAir with.
To provide PurpleAir with the bounding box and the many other fields we want, we use the
requests.get()
function from the requests library. The function allows us to pass an url and a dictionary of query parameters.There is a helper function to create the params dictionary for you, but it may be removed due to redundancy. My suggestion is to use the following code blocks as a guide.
# base url url = "https://www.api.purpleair.com/v1/sensors/" # dictionary of parameters we want to access params = { "fields": "sensor_index,latitude,longitude,temperature,IAQI", "key": read_key, "nwlat": nwlat, "nwlng": nwlng, "selat": selat, "selng": selng, } response = requests.get(url, params=params)
NOTE
If we are to move away from using purpleair as our data source, we can simply change the url and the contents of the
params
dictionary. -
Create the dictionary of data to be returned.
A dictionary named
scores
is created and the data to be returned is inserted. This is what the function ultimately returns to the end user.
If no sensors are found, we return an error message. Otherwise, we take the returned sensor data and format it accordingly to be returned.
To send data back as a json object, we use the jsonify()
function from the flask library.
@api.route("/example")
def example():
return jsonify({
"IAQI": IAQI,
"zipcode": zipcode
})
Creating a new helper function
-
Navigate to
./src/helpers.py
. -
Define a new function which (ideally) is a refactoring of code that would otherwise be put in
./src/app.py
.For example, we defined a function to ping the PurpleAir API, converting zipcodes to their coordinates and creating bounding boxes for those coordinates. The logic of our API has been factored out into
./src/helpers.py
. We use these functions for simplicity and keeping the main./src/app.py
clean. -
Use function in
./src/app.py
.All functions defined within
./src/helpers.py
are imported into./src/app.py
for you. There is no need to import the function manually. Use as necessary.
Deployment
1. Creating an account:
In order to use the API from anywhere on any machine, one would need to deploy the API to a hosting service. The hosting service we picked is render.
(if an account has not already been created) When signing up for an account, make sure to do so using the GitHub account which owns the repository containing the code you'd like to deploy. This is to make it convenient for quickly selecting repositories on your GitHub account direcly from within render.
2. Creating a new service:
- After logging in, click the 'new +' button near the profile icon and select webservice. Selecting webservice allows us to deploy applications that will be serving data to its users. Please check out the Web Services documentation on render.
- Select the GitHub repository containing the code you'd like to deploy. In this case, it will be
cool-corridors-API
- You will now be greeted with this menu to fill out:
Here's what each field mean, feel free to also checkout the documentation:
Name
: A name for your Web ServiceRuntime
: The programming language of your code, which is Python.Region
: The geographic region to deploy toBranch
: The git branch to build. At the moment, the branch used isgunicorn-testing
Root directory
: The directory which the application is ran from. Keep this as default because the root directory contains a file namedrun.py
which is responsble for running the code in/src
Build Command
: The command to build your Web Service. Because all the API dependencies of the project are inrequirements.txt
, set this field topip install -r requirements.txt
. If new packages/dependencies are later on added to the project, runpip freeze > requirements.txt
within the project root directory to get an up to daterequirements.txt
file.Start Command
: The command to start your Web Service. The command isgunicorn run:app
whereapp.py
is in the root directory of the repository. We decided to use gunicorn because it can make the application handle more requests per second. More on this is elaborated below.
Why use gunicorn?
Flask is a light weight framework great for developing applications locally on your machine. However, flask on its own can only handle one request at a time in sequential order, making it less ideal for deployment into production.
Turns out that its best practice to run flask code using gunicorn. This is because gunicorn uses a pre-fork model which has multiple processes called "workers" that can indidivually handle API requests. These processes are handled by a "Master Process" that manages the number of running workers based on how many API requests are made at a given time. The result is that we have a faster application that can handle multiple requests at the same time as opposed to just one at a time.
Plan
Scrolling further down from Start Command
, you can select the plan to host the API with. You can specify the amount of RAM and how many CPUs your Web Service requires. We selected the free plan for now, but higher tiers can handle more traffic load.
Advanced:
Within Advanced
, you may also specify environment variables, secrets, persistent disk, health check path, and whether or not to auto deploy on every git push.
For this section, we need to do two things:
- Add an environment variable to specifiy the Python version for render to use for deployment.
Key: PYTHON_VERSION
Value: 3.9.10
- Add a
.env
file containing PurpleAir API read and write keys. Conventionally,.env
is added to the.gitignore
of project repositories in order to keep senstitive information needed to run the API hidden and safe. However, there's no way for render to have access for this file unless we explicitly specify it here:
-
Filename:
.env
-
contents:
Make sure to have the keys enclosed in quotiation marks READ_KEY = "INSERT_READ_KEY_HERE" WRITE_KEY = "INSERT_WRITE_KEY_HERE"
Once all of this has been set, click the Create Web Service button all the way at the bottom: