Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.idea/
.venv/
__pycache__/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12.4
30 changes: 30 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Coding Challenge

Solution to GistAPI challenge: A simple HTTP API server implemented in Flask for searching a user's public Github Gists.

### To run the application
A Makefile has been used to simplify setup/running the application.

```commandline
make setup
```

#### Run locally
```commandline
make runlocal
```

#### Build and run docker image (using port 5001)
```commandline
make rundocker
```

#### Run tests
```commandline
make runtests
```

#### Run code quality checkers
```commandline
make runcheckers
```
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Use the official Python runtime as a parent image
FROM python:3.12.4-slim-bullseye

# Set the working directory
WORKDIR /app

# Install Poetry
RUN pip install poetry==1.7.1

# Copy only the dependency files first to leverage Docker cache
COPY pyproject.toml poetry.lock /app/

# Install dependencies using Poetry
RUN poetry config virtualenvs.create false && poetry install --no-dev

# Set the FLASK_APP environment variable
ENV FLASK_APP=gistapi/gistapi.py

# Copy the rest of the application code
COPY . /app/

# Expose the port the app runs on
EXPOSE 5000

# Run the Flask app using Poetry
CMD ["poetry", "run", "flask", "run", "--host=0.0.0.0", "--port=5000"]
29 changes: 29 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
VENV := .venv

rm_venv:
source deactivate || true
rm -rf $(VENV)

$(VENV): rm_venv
command -v deactivate && source deactivate || true
python -m venv $(VENV)
source $(VENV)/bin/activate && pip install --upgrade pipx pip-tools

setup:
test -r $(VENV) || make $(VENV)
source $(VENV)/bin/activate && pipx install poetry \
&& poetry install --no-root

runlocal:
$(VENV)/bin/flask --app gistapi/gistapi.py run

rundocker:
docker build --no-cache -t backend-coding-challenge .
docker run -it -p 5001:5000 backend-coding-challenge

runchecks:
$(VENV)/bin/python -m black gistapi/gistapi.py
$(VENV)/bin/python -m flake8 gistapi/gistapi.py

runtests:
$(VENV)/bin/python -m unittest discover tests
70 changes: 70 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Questions and Improvements

#### 1. Can we use a database? What for? SQL or NoSQL?
Yes, we can use a database.
- For repeated queries, we could implement a caching layer using Redis
(or an in-memory cache) instead of using a full database.
- We could store previously fetched gists in a proper database, minimizing
calls to an external, third-party API. Of course this would still mean
making a database call over a network, but should be "faster" for users
who have run previous queries.
- If the application allows users save gists or manage the results, then
a database could be necessary.
- If we need to run some analysis on the results over time, a database
would be necessary.

The choice of SQL or NoSQL depends on the data being saved.

##### SQL (PostgreSQL, MySQL):
- Structured data (e.g. Gist attributes like title, description, URL, etc).
- If there's complex querying or relationships (e.g. user profiles, gist metadata).

##### NoSQL (MongoDB):
- Unstructured, or semi-structured data
- If we need a flexible schema (e.g. users gist content structure can be very different).

#### 2. How can we protect the api from abusing it?
There are a couple of ways we can protect the API from abuse:

- **Rate Limit**: Impose a limit on the number of requests a user can make over a period
of time.
- **API Key**: Make users required to provide an API key when making requests to identify
the user.
- **Implement CORS**: Specify domains that are allowed to access the API.
- **Input Validation**: Validate user input to prevent certain attacks like SQL-injection
- **Use HTTPS**: Using this encrypts the data when in transit, avoiding man-in-the-middle
attacks.
- **Logging and Monitoring**: This can help detect patterns that might indicate something
fishy.

#### 3. How can we deploy the application in a cloud environment?
The application can be deployed to cloud environments like Heroku, AWS (Elastic Beanstalk),
or Google Cloud Platform

To deploy to AWS ElasticBeanstalk:

- Install AWS CLI and EB CLI (Elastic Beanstalk CLI)
- Run aws configure and follow the prompt
- We would need a Procfile (and likely a requirements.txt file)
- Initialize elastic beanstalk using `eb init`
- Create an environment using `eb create`
- Deploy the application using `eb deploy`

Environment variables would need to be added on the platform (e.g. API keys). Some platforms
(e.g. AWS) also offer monitoring (Cloud watch).

#### 4. How can we be sure the application is alive and works as expected when deployed into a cloud environment?
To be sure the application is alive and works as expected we can implement monitoring and testing

- We already have a `/ping` endpoint that can act as a healthcheck endpoint. The provider can use
this endpoint for health checks (Elastic Beanstalk settings has a means to configure this)
- We can implement logging to capture logs (errors, warnings and other important events)
- There are other monitoring tools like Datadog, Sentry, New relic, etc; that help track application
performance, error rates, availability, etc. We can setup alerts when certain thresholds are crossed.
- Automated testing. Tests have been added, but we can also setup CI/CD pipelines to run tests during
deployments and roll-back deployments if tests fail

#### 5. Any other topics you may find interesting and/or important to cover
- API Documentation: Use tools like Swagger to document the API endpoints
- Code coverage. Ensure a robust testing of the codebase, and maintain high code coverage
- Pre-commit/Post-commit hook(s) to run code quality checks and tests before merging code
100 changes: 65 additions & 35 deletions gistapi/gistapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import requests
from flask import Flask, jsonify, request

from utils import gists_for_user, gist_content, is_pattern_present

app = Flask(__name__)

Expand All @@ -21,26 +22,7 @@ def ping():
return "pong"


def gists_for_user(username: str):
"""Provides the list of gist metadata for a given user.

This abstracts the /users/:username/gist endpoint from the Github API.
See https://developer.github.com/v3/gists/#list-a-users-gists for
more information.

Args:
username (string): the user to query gists for

Returns:
The dict parsed from the json response from the Github API. See
the above URL for details of the expected structure.
"""
gists_url = 'https://api.github.com/users/{username}/gists'.format(username=username)
response = requests.get(gists_url)
return response.json()


@app.route("/api/v1/search", methods=['POST'])
@app.route("/api/v1/search", methods=["POST"])
def search():
"""Provides matches for a single pattern across a single users gists.

Expand All @@ -53,24 +35,72 @@ def search():
indicating any failure conditions.
"""
post_data = request.get_json()

username = post_data['username']
pattern = post_data['pattern']

result = {}
gists = gists_for_user(username)
# Validate input data
if not post_data:
return jsonify({"status": "error", "message": "No data provided"}), 400

username = post_data.get("username")
pattern = post_data.get("pattern")
# Validate username and pattern
if not username or not isinstance(username, str):
return (
jsonify({"status": "error", "message": "Invalid or missing username"}),
400,
)
if not pattern or not isinstance(pattern, str):
return (
jsonify({"status": "error", "message": "Invalid or missing pattern"}),
400,
)

result = {
"status": "success",
"username": username,
"pattern": pattern,
"matches": [],
}

try:
gists = gists_for_user(username)
except requests.RequestException as e:
return (
jsonify({"status": "error", "message": f"Error fetching gists: {e}"}),
500,
)

if gists is None:
result["status"] = "error"
result["message"] = (
"Failed to fetch gists. Please check the username and try again."
)
return jsonify(result), 400

for gist in gists:
# TODO: Fetch each gist and check for the pattern
pass

result['status'] = 'success'
result['username'] = username
result['pattern'] = pattern
result['matches'] = []
try:
gist_details = gist_content(gist["url"])
if gist_details is None:
continue

for file_name, file_info in gist_details["files"].items():
if is_pattern_present(file_info["raw_url"], pattern):
result["matches"].append(
{
"gist_id": gist_details["id"],
"gist_url": gist_details["html_url"],
"file_name": file_name,
"file_url": file_info["raw_url"],
}
)
except requests.RequestException as e:
return (
jsonify(
{"status": "error", "message": f"Error fetching gist content: {e}"}
),
500,
)

return jsonify(result)


if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=9876)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=9876)
Loading