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!

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

nametypedata typedescription
zipcoderequiredintzipcode of user's location
api_keyrequiredstringuser's purpleair API key

Responses

http codecontent-typeresponse
200application/jsonJSON object
400application/json{error: "Invalid zipcode"}
400application/json{"error": "No sensors found within the range of "x" miles."}
400application/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

nametypedata typedescription
zipcoderequiredintzipcode of user's location
api_keyrequiredstringuser's purpleair API key

Responses

http codecontent-typeresponse
200application/jsonJSON object
400application/json{error: "Invalid zipcode"}
400application/json{"error": "No sensors found within the range of "x" miles."}
400application/json{"error": "No read key or zipcode provided."}

Example response

   {
       "IAQI": 105,
       "zipcode": 11101
   }

GET /descriptor

Parameters

nametypedata typedescription
zipcoderequiredintzipcode of user's location
api_keyrequiredstringuser's purpleair API key

Responses

http codecontent-typeresponse
200application/jsonJSON object
400application/json{error: "Invalid zipcode"}
400application/json{"error": "No sensors found within the range of "x" miles."}
400application/json{"error": "No read key or zipcode provided."}

Example response

   {
       "description": "Good",
       "zipcode": 11101
   }

GET /color

Parameters

nametypedata typedescription
zipcoderequiredintzipcode of user's location
api_keyrequiredstringuser's purpleair API key

Responses

http codecontent-typeresponse
200application/jsonJSON object
400application/json{error: "Invalid zipcode"}
400application/json{"error": "No sensors found within the range of "x" miles."}
400application/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):

Coolcorridors Prototype Demo

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 endpoint
  • search-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:

home route

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 in app.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 within layout.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 to layout.jinja while static/search.css is applied on to search.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:

zipcode search results

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:

About Page Skeleton

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.

  1. Go to the repository you wish to contribute to.

  2. Copy the repository link image

  3. 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:

  1. Get the url from GitHub. Use the above image for assistance.

  2. 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.

  1. Navigate to ./src/app.py.

  2. Define a new route (name and methods) through the following syntax:

    @api.route("/test", methods=[GET,POST])
    def test():
        return "Hello World!"
    

    An example endpoint called "test" that takes methods GET and POST

    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.

  1. 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...

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:

  1. Obtain zip_code and api_key

    Query strings are accessed through the flask request object.

    zipcode = request.args.get("zipcode")
    read_key = request.args.get("read_key")
    

    A query string is anything after a ? in the url. They have a name, value, and separated by a &.

    If the zipcode or read key is not provided, we return an error message.

  2. Convert zipcode into lat/long and create binding box.

    To convert a zipcode, we have a lookup table called ZIP_DICT. Access it using lat,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)
    
  3. 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.

  4. 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

  1. Navigate to ./src/helpers.py.

  2. 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.

  3. 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:

  1. 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.

image

  1. Select the GitHub repository containing the code you'd like to deploy. In this case, it will be cool-corridors-API

image

  1. You will now be greeted with this menu to fill out:

image

Here's what each field mean, feel free to also checkout the documentation:

  • Name: A name for your Web Service
  • Runtime: The programming language of your code, which is Python.
  • Region: The geographic region to deploy to
  • Branch: The git branch to build. At the moment, the branch used is gunicorn-testing
  • Root directory: The directory which the application is ran from. Keep this as default because the root directory contains a file named run.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 in requirements.txt, set this field to pip install -r requirements.txt. If new packages/dependencies are later on added to the project, run pip freeze > requirements.txt within the project root directory to get an up to date requirements.txt file.
  • Start Command: The command to start your Web Service. The command is gunicorn run:app where app.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.

article

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.

image

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.

image

For this section, we need to do two things:

  1. Add an environment variable to specifiy the Python version for render to use for deployment.
Key: PYTHON_VERSION
Value: 3.9.10

image

  1. 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:

image

  • 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:

image