├── LICENSE ├── README.md ├── api-gateway ├── integration-request │ └── mapping_template.json └── swagger.yml ├── iam-policies └── lambda-dynamodb-url-shortener.json ├── url-shortener-create └── lambda_handler.py └── url-shortener-retrieve └── lambda_handler.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ruan Bekker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-serverless-url-shortener 2 | Serverless URL Shortener on AWS with API Gateway, Lambda, DynamoDB and Python 3 | 4 | ## Demonstration 5 | 6 | Tutorial on how to setup a serverless URL shortener has been published on [https://blog.ruanbekker.com/blog/2018/11/30/how-to-setup-a-serverless-url-shortener-with-api-gateway-lambda-and-dynamodb-on-aws/](https://blog.ruanbekker.com/blog/2018/11/30/how-to-setup-a-serverless-url-shortener-with-api-gateway-lambda-and-dynamodb-on-aws/?referral=github.com) 7 | 8 | ## Todo 9 | 10 | I've published a post on how to setup a Python Flask and Javascript UI thats avaialable here: [https://blog.ruanbekker.com/blog/2018/12/18/creating-a-ui-in-python-flask-and-bootstrap-for-our-serverless-url-shortener/](https://blog.ruanbekker.com/blog/2018/12/18/creating-a-ui-in-python-flask-and-bootstrap-for-our-serverless-url-shortener/?referral=github.com) 11 | 12 | I will still look into making it more static with pure JS and hosting it on S3 to make it more serverless'ish 13 | -------------------------------------------------------------------------------- /api-gateway/integration-request/mapping_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_id": "$input.params('shortid')" 3 | } 4 | -------------------------------------------------------------------------------- /api-gateway/swagger.yml: -------------------------------------------------------------------------------- 1 | --- 2 | swagger: "2.0" 3 | info: 4 | version: "2018-10-08T23:43:53Z" 5 | title: "url-shortener-api" 6 | host: "tiny.yourdomain.com" 7 | schemes: 8 | - "https" 9 | paths: 10 | /create: 11 | post: 12 | produces: 13 | - "application/json" 14 | responses: 15 | 200: 16 | description: "200 response" 17 | schema: 18 | $ref: "#/definitions/Empty" 19 | x-amazon-apigateway-integration: 20 | uri: "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:${ACCOUNT_ID}:function:url-shortener-create/invocations" 21 | responses: 22 | default: 23 | statusCode: "200" 24 | passthroughBehavior: "when_no_match" 25 | httpMethod: "POST" 26 | contentHandling: "CONVERT_TO_TEXT" 27 | type: "aws_proxy" 28 | /t/{shortid}: 29 | get: 30 | consumes: 31 | - "application/json" 32 | parameters: 33 | - name: "shortid" 34 | in: "path" 35 | required: true 36 | type: "string" 37 | responses: 38 | 301: 39 | description: "301 response" 40 | headers: 41 | Location: 42 | type: "string" 43 | x-amazon-apigateway-integration: 44 | uri: "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:${ACCOUNT_ID}:function:url-shortener-retrieve/invocations" 45 | responses: 46 | default: 47 | statusCode: "301" 48 | responseParameters: 49 | method.response.header.Location: "integration.response.body.location" 50 | passthroughBehavior: "when_no_templates" 51 | httpMethod: "POST" 52 | requestTemplates: 53 | application/json: "{\n \"short_id\": \"$input.params('shortid')\"\n}" 54 | contentHandling: "CONVERT_TO_TEXT" 55 | type: "aws" 56 | definitions: 57 | Empty: 58 | type: "object" 59 | title: "Empty Schema" 60 | -------------------------------------------------------------------------------- /iam-policies/lambda-dynamodb-url-shortener.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "VisualEditor0", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "dynamodb:PutItem", 9 | "dynamodb:DeleteItem", 10 | "dynamodb:GetItem", 11 | "dynamodb:Query", 12 | "dynamodb:UpdateItem" 13 | ], 14 | "Resource": "arn:aws:dynamodb:eu-west-1:xxxxxxxxxxxx:table/url-shortener-table" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /url-shortener-create/lambda_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import boto3 4 | from string import ascii_letters, digits 5 | from random import choice, randint 6 | from time import strftime, time 7 | from urllib import parse 8 | 9 | app_url = os.getenv('APP_URL') 10 | min_char = int(os.getenv('MIN_CHAR')) 11 | max_char = int(os.getenv('MAX_CHAR')) 12 | string_format = ascii_letters + digits 13 | 14 | ddb = boto3.resource('dynamodb', region_name = 'eu-west-1').Table('url-shortener-table') 15 | 16 | def generate_timestamp(): 17 | response = strftime("%Y-%m-%dT%H:%M:%S") 18 | return response 19 | 20 | def expiry_date(): 21 | response = int(time()) + int(604800) 22 | return response 23 | 24 | def check_id(short_id): 25 | if 'Item' in ddb.get_item(Key={'short_id': short_id}): 26 | response = generate_id() 27 | else: 28 | return short_id 29 | 30 | def generate_id(): 31 | short_id = "".join(choice(string_format) for x in range(randint(min_char, max_char))) 32 | print(short_id) 33 | response = check_id(short_id) 34 | return response 35 | 36 | def lambda_handler(event, context): 37 | analytics = {} 38 | print(event) 39 | short_id = generate_id() 40 | short_url = app_url + short_id 41 | long_url = json.loads(event.get('body')).get('long_url') 42 | timestamp = generate_timestamp() 43 | ttl_value = expiry_date() 44 | 45 | analytics['user_agent'] = event.get('headers').get('User-Agent') 46 | analytics['source_ip'] = event.get('headers').get('X-Forwarded-For') 47 | analytics['xray_trace_id'] = event.get('headers').get('X-Amzn-Trace-Id') 48 | 49 | if len(parse.urlsplit(long_url).query) > 0: 50 | url_params = dict(parse.parse_qsl(parse.urlsplit(long_url).query)) 51 | for k in url_params: 52 | analytics[k] = url_params[k] 53 | 54 | response = ddb.put_item( 55 | Item={ 56 | 'short_id': short_id, 57 | 'created_at': timestamp, 58 | 'ttl': int(ttl_value), 59 | 'short_url': short_url, 60 | 'long_url': long_url, 61 | 'analytics': analytics, 62 | 'hits': int(0) 63 | } 64 | ) 65 | 66 | return { 67 | "statusCode": 200, 68 | "body": short_url 69 | } 70 | -------------------------------------------------------------------------------- /url-shortener-retrieve/lambda_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import boto3 4 | 5 | ddb = boto3.resource('dynamodb', region_name = 'eu-west-1').Table('url-shortener-table') 6 | 7 | def lambda_handler(event, context): 8 | short_id = event.get('short_id') 9 | 10 | try: 11 | item = ddb.get_item(Key={'short_id': short_id}) 12 | long_url = item.get('Item').get('long_url') 13 | # increase the hit number on the db entry of the url (analytics?) 14 | ddb.update_item( 15 | Key={'short_id': short_id}, 16 | UpdateExpression='set hits = hits + :val', 17 | ExpressionAttributeValues={':val': 1} 18 | ) 19 | 20 | except: 21 | return { 22 | 'statusCode': 301, 23 | 'location': 'https://objects.ruanbekker.com/assets/images/404-blue.jpg' 24 | } 25 | 26 | return { 27 | "statusCode": 301, 28 | "location": long_url 29 | } 30 | --------------------------------------------------------------------------------