Implement API to add new short urls
This commit is contained in:
parent
576457470a
commit
2c435c6199
18 changed files with 272 additions and 41 deletions
|
@ -1,3 +1,8 @@
|
|||
export FLASK_APP=shortener.main
|
||||
export FLASK_ENV=development
|
||||
export BASE_DOMAIN=g4b.ovh.
|
||||
export BASE_DOMAIN=g4b.ovh
|
||||
export PROVIDER=ovh
|
||||
export OVH_ENDPOINT=ovh-eu
|
||||
export OVH_APPLICATION_KEY=
|
||||
export OVH_APPLICATION_SECRET=
|
||||
export OVH_CONSUMER_KEY=
|
||||
|
|
33
poetry.lock
generated
33
poetry.lock
generated
|
@ -161,6 +161,18 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "ovh"
|
||||
version = "0.5.0"
|
||||
description = "\"Official OVH.com API wrapper\""
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage (==3.7.1)", "mock (==1.0.1)", "nose (==1.3.3)", "yanc (==0.2.4)", "Sphinx (==1.2.2)", "coveralls (==0.4.4)", "setuptools (>=30.3.0)", "wheel"]
|
||||
test = ["coverage (==3.7.1)", "mock (==1.0.1)", "nose (==1.3.3)", "yanc (==0.2.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "20.9"
|
||||
|
@ -245,6 +257,17 @@ category = "dev"
|
|||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "3.5.3"
|
||||
description = "Python client for Redis key-value store"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.extras]
|
||||
hiredis = ["hiredis (>=0.1.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
|
@ -301,7 +324,7 @@ watchdog = ["watchdog"]
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "dfd3993450db4fbb1625a378f1c1018769d937dfc7175ef464474ac9388deef9"
|
||||
content-hash = "7bb0d58e52c792760a354cbff8e7ae8b642d5d29fabf3210a1b32d1f5e025a81"
|
||||
|
||||
[metadata.files]
|
||||
appdirs = [
|
||||
|
@ -400,6 +423,10 @@ nodeenv = [
|
|||
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
|
||||
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
|
||||
]
|
||||
ovh = [
|
||||
{file = "ovh-0.5.0-py2.py3-none-any.whl", hash = "sha256:03c7a5e7a62e7bc09b899f7692c694360be7db93ebe44428d6605fccda244692"},
|
||||
{file = "ovh-0.5.0.tar.gz", hash = "sha256:f74d190c4bff0953d76124cb8ed319a8a999138720e42957f0db481ef4746ae8"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
|
||||
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
|
||||
|
@ -447,6 +474,10 @@ pyyaml = [
|
|||
{file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"},
|
||||
{file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"},
|
||||
]
|
||||
redis = [
|
||||
{file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"},
|
||||
{file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||
|
|
|
@ -8,6 +8,8 @@ authors = ["Gabriel Augendre <gabriel@augendre.info>"]
|
|||
python = "^3.9"
|
||||
dnspython = "^2.1.0"
|
||||
Flask = "^2.0.0"
|
||||
ovh = "^0.5.0"
|
||||
redis = "^3.5.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^5.2"
|
||||
|
|
6
shortener/api_connectors/base_api_connector.py
Normal file
6
shortener/api_connectors/base_api_connector.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from shortener.data_structures import UserInput
|
||||
|
||||
|
||||
class BaseApiConnector:
|
||||
def add_record(self, user_input: UserInput) -> None:
|
||||
raise NotImplementedError("Subclasses must implement add_record")
|
0
shortener/api_connectors/ovh/__init__.py
Normal file
0
shortener/api_connectors/ovh/__init__.py
Normal file
38
shortener/api_connectors/ovh/ovh_api.py
Normal file
38
shortener/api_connectors/ovh/ovh_api.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
import os
|
||||
|
||||
import ovh
|
||||
from ovh import HTTPError, InvalidResponse
|
||||
|
||||
from shortener.api_connectors.base_api_connector import BaseApiConnector
|
||||
from shortener.data_structures import UserInput
|
||||
from shortener.exceptions import ApiException
|
||||
|
||||
|
||||
class OvhApi(BaseApiConnector):
|
||||
def __init__(self):
|
||||
endpoint = os.environ["OVH_ENDPOINT"]
|
||||
application_key = os.environ["OVH_APPLICATION_KEY"]
|
||||
application_secret = os.environ["OVH_APPLICATION_SECRET"]
|
||||
consumer_key = os.environ["OVH_CONSUMER_KEY"]
|
||||
self._base_domain = os.environ["BASE_DOMAIN"]
|
||||
self._client = ovh.Client(
|
||||
endpoint=endpoint,
|
||||
application_key=application_key,
|
||||
application_secret=application_secret,
|
||||
consumer_key=consumer_key,
|
||||
)
|
||||
|
||||
def add_record(self, user_input: UserInput) -> None:
|
||||
try:
|
||||
self._add_record(user_input)
|
||||
except (HTTPError, InvalidResponse) as e:
|
||||
raise ApiException(message=str(e))
|
||||
|
||||
def _add_record(self, user_input: UserInput) -> None:
|
||||
self._client.post(
|
||||
f"/domain/zone/{self._base_domain}/record",
|
||||
fieldType="TXT",
|
||||
subDomain=user_input.short_code,
|
||||
target=user_input.url,
|
||||
)
|
||||
self._client.post(f"/domain/zone/{self._base_domain}/refresh")
|
8
shortener/cache.py
Normal file
8
shortener/cache.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
import os
|
||||
|
||||
import redis
|
||||
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
|
||||
REDIS_DB = int(os.getenv("REDIS_DB", 0))
|
||||
cache = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB)
|
13
shortener/data_structures.py
Normal file
13
shortener/data_structures.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from flask import request
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserInput:
|
||||
short_code: str
|
||||
url: str
|
||||
|
||||
@property
|
||||
def short_url(self):
|
||||
return f"{request.host_url}{self.short_code}"
|
|
@ -7,3 +7,7 @@ class ApiException(Exception):
|
|||
class BadRequestException(ApiException):
|
||||
def __init__(self, message="Bad request", code=400):
|
||||
super().__init__(message, code)
|
||||
|
||||
|
||||
class ShortCodeNotFoundException(Exception):
|
||||
pass
|
||||
|
|
|
@ -1,27 +1,72 @@
|
|||
import os
|
||||
from typing import Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import request
|
||||
|
||||
from shortener.exceptions import BadRequestException
|
||||
from shortener.ovh.ovh_api import OvhApi
|
||||
from shortener.api_connectors.base_api_connector import BaseApiConnector
|
||||
from shortener.api_connectors.ovh.ovh_api import OvhApi
|
||||
from shortener.cache import cache
|
||||
from shortener.data_structures import UserInput
|
||||
from shortener.exceptions import BadRequestException, ShortCodeNotFoundException
|
||||
from shortener.resolver import resolver
|
||||
|
||||
SUPPORTED_PROVIDERS = {"ovh": OvhApi}
|
||||
|
||||
|
||||
def handle_expand(shortcode, resolver):
|
||||
answer = resolver.resolve(shortcode, "TXT", search=True, lifetime=10)
|
||||
redirect_url = list(answer)[0].strings[0].decode("utf-8")
|
||||
return redirect_url
|
||||
SUPPORTED_PROVIDERS: Dict[str, type] = {"ovh": OvhApi}
|
||||
PROVIDER_NAME = os.environ["PROVIDER"].lower()
|
||||
PROVIDER_CLASS = SUPPORTED_PROVIDERS[PROVIDER_NAME]
|
||||
COOLDOWN = "cooldown"
|
||||
|
||||
|
||||
def handle_shorten():
|
||||
user_input = request.json
|
||||
print(user_input)
|
||||
provider = user_input.get("provider")
|
||||
if not provider:
|
||||
raise BadRequestException(message="`provider` is required")
|
||||
provider_class = SUPPORTED_PROVIDERS.get(provider)
|
||||
if not provider_class:
|
||||
supported_providers = list(SUPPORTED_PROVIDERS.keys())
|
||||
user_input: UserInput = _validate_user_input()
|
||||
cooldown = cache.get(user_input.short_code)
|
||||
if cooldown:
|
||||
raise BadRequestException(
|
||||
message=f"`provider` is not supported. Must be one of {supported_providers}"
|
||||
message=f"Please wait before submitting again for {user_input.short_code}."
|
||||
)
|
||||
return {"status": "ok"}
|
||||
provider: BaseApiConnector = _get_provider()
|
||||
provider.add_record(user_input)
|
||||
cache.set(user_input.short_code, COOLDOWN, ex=90)
|
||||
return {"status": "ok", "url": user_input.short_url}
|
||||
|
||||
|
||||
def _validate_user_input():
|
||||
user_input = request.json
|
||||
if not user_input:
|
||||
user_input = request.form
|
||||
url = user_input.get("url")
|
||||
if not url:
|
||||
raise BadRequestException(message="`url` is required")
|
||||
parsed_url = urlparse(url)
|
||||
if not all([parsed_url.scheme, parsed_url.netloc]):
|
||||
raise BadRequestException(
|
||||
message="`url` doesn't seem well formatted. "
|
||||
"Please include at least protocol and domain name. "
|
||||
"E.g. 'https://google.com' instead of 'google.com'."
|
||||
)
|
||||
|
||||
short_code = user_input.get("short_code")
|
||||
if not short_code:
|
||||
raise BadRequestException(message="`short_code` is required")
|
||||
short_code = str(short_code)
|
||||
if len(short_code) < 4:
|
||||
raise BadRequestException(
|
||||
message="`short_code` must be longer than 4 characters."
|
||||
)
|
||||
if _short_code_exists(short_code):
|
||||
raise BadRequestException(message=f"`{short_code}` is already taken")
|
||||
|
||||
user_input = UserInput(short_code=short_code, url=url)
|
||||
return user_input
|
||||
|
||||
|
||||
def _get_provider() -> BaseApiConnector:
|
||||
return PROVIDER_CLASS()
|
||||
|
||||
|
||||
def _short_code_exists(short_code):
|
||||
try:
|
||||
resolver.resolve_shortcode(short_code)
|
||||
return True
|
||||
except ShortCodeNotFoundException:
|
||||
return False
|
||||
|
|
|
@ -1,39 +1,40 @@
|
|||
import os
|
||||
|
||||
from dns.name import from_text
|
||||
from dns.resolver import LRUCache, Resolver
|
||||
from flask import Flask, redirect
|
||||
from flask import Flask, redirect, render_template, request
|
||||
|
||||
from shortener.exceptions import ApiException
|
||||
from shortener.handlers import handle_expand, handle_shorten
|
||||
|
||||
BASE_DOMAIN = os.getenv("BASE_DOMAIN")
|
||||
from shortener.exceptions import ApiException, ShortCodeNotFoundException
|
||||
from shortener.handlers import handle_shorten
|
||||
from shortener.resolver import resolver
|
||||
|
||||
app = Flask(__name__)
|
||||
resolver = Resolver()
|
||||
resolver.search = [from_text(BASE_DOMAIN)]
|
||||
resolver.cache = LRUCache()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return "<p>Welcome to the url shortener!</p>"
|
||||
return render_template("welcome.html")
|
||||
|
||||
|
||||
@app.route("/shorten/", methods=["POST"])
|
||||
@app.route("/shorten", methods=["POST"])
|
||||
def shorten_url():
|
||||
try:
|
||||
return handle_shorten()
|
||||
result = handle_shorten()
|
||||
code = 200
|
||||
except ApiException as e:
|
||||
return {"status": "error", "message": e.message}, e.code
|
||||
result = {"message": e.message, "status": "error"}
|
||||
code = e.code
|
||||
is_api = bool(request.json)
|
||||
if is_api:
|
||||
return result, code
|
||||
|
||||
return render_template("result.html", **result), code
|
||||
|
||||
|
||||
@app.route("/<string:shortcode>/")
|
||||
@app.route("/<string:shortcode>")
|
||||
def expand_url(shortcode: str):
|
||||
try:
|
||||
redirect_url = handle_expand(shortcode, resolver)
|
||||
except:
|
||||
return "<p>URL not found</p>"
|
||||
redirect_url = resolver.resolve_shortcode(shortcode)
|
||||
except ShortCodeNotFoundException:
|
||||
return render_template("not_found.html")
|
||||
return redirect(redirect_url, code=302)
|
||||
|
||||
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
class OvhApi:
|
||||
pass
|
26
shortener/resolver.py
Normal file
26
shortener/resolver.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
import os
|
||||
|
||||
from dns.name import from_text
|
||||
from dns.resolver import LRUCache, Resolver
|
||||
|
||||
from shortener.exceptions import ShortCodeNotFoundException
|
||||
|
||||
BASE_DOMAIN = os.getenv("BASE_DOMAIN")
|
||||
|
||||
|
||||
class ShortenerResolver(Resolver):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.search = [from_text(BASE_DOMAIN + ".")]
|
||||
self.cache = LRUCache()
|
||||
|
||||
def resolve_shortcode(self, shortcode):
|
||||
try:
|
||||
answer = self.resolve(shortcode, "TXT", search=True, lifetime=10)
|
||||
redirect_url = list(answer)[0].strings[0].decode("utf-8")
|
||||
return redirect_url
|
||||
except:
|
||||
raise ShortCodeNotFoundException()
|
||||
|
||||
|
||||
resolver = ShortenerResolver()
|
11
shortener/templates/base.html
Normal file
11
shortener/templates/base.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>URL shortener</title>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
7
shortener/templates/not_found.html
Normal file
7
shortener/templates/not_found.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>URL not found</h1>
|
||||
<p>If you recently created the redirect, please wait a couple of minutes for DNS to propagate.</p>
|
||||
<p>If you've been sent this URL, please contact the sender. It's likely there's an issue with the URL you received.</p>
|
||||
{% endblock %}
|
22
shortener/templates/result.html
Normal file
22
shortener/templates/result.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Result</h1>
|
||||
<p class="status-{{ status }}">Status: {{ status }}</p>
|
||||
{% if message %}
|
||||
<p>{{ message }}</p>
|
||||
{% endif %}
|
||||
{% if url %}
|
||||
<p>
|
||||
Your short URL is: <a href="{{ url }}">{{ url }}</a>.<br>
|
||||
Please wait a couple of minutes for DNS to propagate before testing and
|
||||
sharing this URL.
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a onclick="window.history.back(); return false;"
|
||||
href="{{ url_for("home") }}">
|
||||
Go back
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
14
shortener/templates/welcome.html
Normal file
14
shortener/templates/welcome.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Welcome!</h1>
|
||||
<p>Welcome to the URL shortener.</p>
|
||||
<form action="{{ url_for("shorten_url") }}" method="post">
|
||||
<label for="short_code">Short code</label>
|
||||
<input type="text" name="short_code" id="short_code">
|
||||
<label for="url">Url to shorten</label>
|
||||
<input type="text" name="url" id="url">
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
</form>
|
||||
{% endblock %}
|
Loading…
Reference in a new issue