├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── assets ├── class_diagram.png ├── cover.png ├── hexagonal.png └── system_design.png ├── go.mod ├── go.sum ├── internal ├── adapters │ ├── cache │ │ └── redis.go │ ├── functions │ │ ├── delete │ │ │ └── main.go │ │ ├── generate │ │ │ └── main.go │ │ ├── notification │ │ │ └── main.go │ │ ├── redirect │ │ │ └── main.go │ │ └── stats │ │ │ └── main.go │ ├── handlers │ │ ├── delete.go │ │ ├── generate.go │ │ ├── helpers.go │ │ ├── redirect.go │ │ ├── slack.go │ │ └── stats.go │ └── repository │ │ ├── link.go │ │ └── stats.go ├── config │ └── config.go ├── core │ ├── domain │ │ ├── link.go │ │ └── stats.go │ ├── ports │ │ ├── cache.go │ │ ├── link.go │ │ └── statistics.go │ └── services │ │ ├── link.go │ │ └── stats.go └── tests │ ├── benchmark │ └── link_benchmark_test.go │ ├── mock │ ├── data.go │ ├── mock_cache.go │ ├── mock_link.go │ └── mock_stats.go │ └── unit │ ├── generate_link_test.go │ ├── helpers.go │ ├── redirect_link_test.go │ ├── slack_test.go │ └── stats_test.go └── template.yaml /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy-with-sam: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: '1.21' 20 | 21 | - name: Unit-Test 22 | run: make unit-test 23 | 24 | - name: Benchmark-Test 25 | run: make benchmark-test 26 | 27 | - name: Build 28 | if: success() 29 | run: make build 30 | 31 | - name: Configure AWS credentials 32 | if: success() 33 | uses: aws-actions/configure-aws-credentials@v1 34 | with: 35 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 36 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 37 | aws-region: eu-central-1 38 | 39 | - name: Deploy 40 | if: success() 41 | run: | 42 | sam deploy --stack-name golang-url-shortener --capabilities CAPABILITY_IAM --resolve-s3 --no-confirm-changeset --no-fail-on-empty-changeset 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.test 7 | *.out 8 | go.work 9 | samconfig.toml 10 | **/bootstrap 11 | .terraform* 12 | terraform* 13 | .DS_Store 14 | .env 15 | dump.rdb -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 FURKAN GULSEN 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STACK_NAME ?= golang-url-shortener 2 | FUNCTIONS := generate redirect stats notification delete 3 | REGION := eu-central-1 4 | 5 | GO := go 6 | 7 | build: 8 | ${MAKE} ${MAKEOPTS} $(foreach function,${FUNCTIONS}, build-${function}) 9 | 10 | build-%: 11 | cd internal/adapters/functions/$* && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 ${GO} build -o bootstrap 12 | 13 | clean: 14 | @rm $(foreach function,${FUNCTIONS}, internal/adapters/functions/${function}/bootstrap) 15 | 16 | deploy: 17 | if [ -f samconfig.toml ]; \ 18 | then sam deploy --stack-name ${STACK_NAME}; \ 19 | else sam deploy -g --stack-name ${STACK_NAME}; \ 20 | fi 21 | 22 | unit-test: 23 | cd internal/tests/unit/$* && ${GO} test -v . 24 | 25 | benchmark-test: 26 | cd internal/tests/benchmark/$* && ${GO} test -v -bench=. 27 | 28 | delete: 29 | sam delete --stack-name ${STACK_NAME} 30 | 31 | STATICCHECK = $(GOBIN)/staticcheck 32 | 33 | GO_FILES := $(shell \ 34 | find . '(' -path '*/.*' -o -path './vendor' ')' -prune \ 35 | -o -name '*.go' -print | cut -b3-) 36 | MODULE_DIRS = . 37 | 38 | 39 | .PHONY: lint 40 | lint: $(STATICCHECK) 41 | @rm -rf lint.log 42 | @echo "Checking formatting..." 43 | @gofmt -d -s $(GO_FILES) 2>&1 | tee lint.log 44 | @echo "Checking vet..." 45 | @$(foreach dir,$(MODULE_DIRS),(cd $(dir) && go vet ./... 2>&1) &&) true | tee -a lint.log 46 | @echo "Checking staticcheck..." 47 | @$(foreach dir,$(MODULE_DIRS),(cd $(dir) && $(STATICCHECK) ./... 2>&1) &&) true | tee -a lint.log 48 | @echo "Checking for unresolved FIXMEs..." 49 | @git grep -i fixme | grep -v -e Makefile | tee -a lint.log 50 | @[ ! -s lint.log ] 51 | @rm lint.log 52 | @echo "Checking 'go mod tidy'..." 53 | @make tidy 54 | @if ! git diff --quiet; then \ 55 | echo "'go diff tidy' resulted in chnges or working tree is dirty:"; \ 56 | git --no-pager diff; \ 57 | fi 58 | 59 | $(STATICCHECK): 60 | cd tools && go install honnef.co/go/tools/cmd/staticcheck 61 | 62 | .PHONY: tidy 63 | tidy: 64 | @$(foreach dir,$(MODULE_DIRS),(cd $(dir) && go mod tidy) &&) true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Golang URL Shortener 2 | 3 | ![Cover image](./assets/cover.png) 4 | 5 | This URL shortener service, built with Go and Hexagonal Architecture, leverages a serverless approach for efficient scalability and performance. It uses a variety of AWS services to provide a robust, maintainable, and highly available URL shortening service. 6 | 7 | - [Functional Requirements](#functional-requirements) 8 | - [Non-Functional Requirements](#non-functional-requirements) 9 | - [Features](#features) 10 | - [Prerequisites](#prerequisites) 11 | - [Technologies Used](#technologies-used) 12 | - [System Architecture](#system-architecture) 13 | - [Class Diagram](#class-diagram) 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | - [Deploying to AWS Lambda](#deploying-to-aws-lambda) 17 | - [Running Tests](#running-tests) 18 | - [Cleaning Up](#cleaning-up) 19 | - [Cost Estimate](#cost-estimate) 20 | - [Hexagonal Architecture](#hexagonal-architecture) 21 | - [Internal Directory](#internal-directory) 22 | - [Serverless and Hexagonal Architecture](#serverless-and-hexagonal-architecture) 23 | - [License](#license) 24 | 25 | ## Functional Requirements 26 | 27 | - **Shortening URLs**: Users should be able to input a URL and receive a shortened version. 28 | - **URL Redirection**: When accessing a shortened URL, users should be redirected to the original URL. 29 | - **Analytics**: Provide analytics for each shortened URL, including click counts and basic usage stats. 30 | - **API Access**: Offer API endpoints for creating, retrieving, and managing shortened URLs. 31 | - **User Notifications**: Send notifications for specific actions like URL creation or deletion (Slack). 32 | 33 | ## Non-Functional Requirements 34 | 35 | - **Scalability**: The system must automatically scale to handle varying loads. 36 | - **Performance**: High performance in URL redirection and shortening operations. 37 | - **Reliability**: Ensure high availability and fault tolerance. 38 | - **Security**: Implement security measures to prevent unauthorized access and abuse. 39 | - **Maintainability**: Code should be well-organized, documented, and adhering to Hexagonal Architecture principles for easy maintenance. 40 | - **Monitoring and Logging**: Implement comprehensive monitoring and logging for troubleshooting and performance tracking. 41 | 42 | ## Features 43 | 44 | - **URL Generation**: Create shortened URLs efficiently. 45 | - **Redirection**: Redirect users to original URLs via shortened links. 46 | - **Stats**: Gather and display usage stats of shortened URLs. 47 | - **Notification**: Notify users or systems of certain actions or events. 48 | - **Deletion**: Safely remove shortened URLs and their associated data. 49 | 50 | ## Prerequisites 51 | 52 | - Go (Golang) installed on your system. 53 | - AWS SAM CLI for deploying serverless functions. 54 | - Access to AWS Lambda and related AWS services. 55 | 56 | ## Technologies Used 57 | 58 | - **Go (Golang)**: The primary programming language used for development. 59 | - **AWS DynamoDB**: A NoSQL database service used for storing and retrieving data efficiently. 60 | - **ElastiCache(Redis)**: An in-memory data structure store, used as a cache and message broker. 61 | - **AWS CloudFormation**: A service for defining and deploying infrastructure as code, ensuring consistent and repeatable architectural deployments. 62 | - **AWS SQS (Simple Queue Service)**: A message queuing service used to decouple and scale microservices, distributed systems, and serverless applications. 63 | - **GitHub Actions**: Automated CI/CD platform used for building, testing, and deploying code directly from GitHub repositories. 64 | - **AWS Lambda**: A serverless compute service that lets you run code without provisioning or managing servers, automatically scaling with usage. 65 | - **AWS CloudFront**: A fast content delivery network (CDN) service that securely delivers data, videos, applications, and APIs to customers globally with low latency and high transfer speeds. 66 | - **AWS API Gateway**: A fully managed service that makes it easy for developers to create, publish, maintain, monitor, and secure APIs at any scale. 67 | 68 | ## System Architecture 69 | 70 | ![system design](./assets/system_design.png) 71 | 72 | ## Class Diagram 73 | 74 | ![class diagram](./assets/class_diagram.png) 75 | 76 | ## Installation 77 | 78 | 1. **Clone the Repository**: 79 | 80 | ```bash 81 | git clone https://github.com/Furkan-Gulsen/golang-url-shortener.git 82 | cd golang-url-shortener 83 | ``` 84 | 85 | 2. **Build the Project**: 86 | Use the Makefile to build the project: 87 | ```bash 88 | make build 89 | ``` 90 | 91 | ## Usage 92 | 93 | ### Deploying to AWS Lambda 94 | 95 | - Deploy your functions to AWS Lambda using the following command: 96 | ```bash 97 | make deploy 98 | ``` 99 | This command will use AWS SAM to deploy your serverless functions. 100 | 101 | ### Running Tests 102 | 103 | - **Unit Tests**: 104 | Run unit tests for specific functions: 105 | 106 | ```bash 107 | make unit-test 108 | ``` 109 | 110 | - **Benchmark Tests**: 111 | Perform benchmark tests: 112 | ```bash 113 | make benchmark-test 114 | ``` 115 | 116 | ### Cleaning Up 117 | 118 | - To clean up the build artifacts: 119 | 120 | ```bash 121 | make clean 122 | ``` 123 | 124 | - To delete the deployed stack: 125 | ```bash 126 | make delete 127 | ``` 128 | 129 | ## Cost Estimate 130 | 131 | | AWS Service | Estimated Cost for 1 Million Requests | Notes | 132 | | ------------------- | ------------------------------------- | -------------------------------------------------------------------------------- | 133 | | AWS Lambda | $0.20 | First 1M requests/month are free, after that, it's $0.20 per 1 million requests | 134 | | API Gateway | $3.50 | First 1M requests/month are free, after that, it's $3.50 per 1 million request. | 135 | | DynamoDB | $1.25 | 2 writes per generate, 1 write per redirect, 2 reads per stats | 136 | | CloudFront | $0.75 - $2.20 | Based on 1M HTTPS requests | 137 | | ElastiCache (Redis) | Variable | Dependent on node size and time running | 138 | | SQS | $0.40 | First 1M requests/month are free, after that, it's $0.40 per 1 million requests. | 139 | 140 | > Warning: This is a calculation based entirely on guesswork. 141 | 142 | --- 143 | 144 | ## Hexagonal Architecture 145 | 146 | ![Hexagonal Architecture](./assets/hexagonal.png) 147 | 148 | Image Resource: https://aws.amazon.com/blogs/compute/developing-evolutionary-architecture-with-aws-lambda 149 | 150 | Hexagonal Architecture in a serverless context, specifically using Go language, is a fascinating topic that combines modern architectural patterns with the agility and scalability of serverless computing. Let's break down the key concepts and how your project structure fits into this model. 151 | 152 | 1. **Core Concept**: Hexagonal Architecture, also known as Ports and Adapters Architecture, is designed to create a loosely coupled application that isolates the core logic from external concerns. The idea is to allow an application to be equally driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual runtime devices and databases. 153 | 154 | 2. **Ports and Adapters**: In this architecture, the 'ports' are the interfaces that define how data can enter and leave the application or system. The 'adapters' are implementations that interact with the outside world, such as a database, a web server, or other systems. 155 | 156 | ### Internal Directory 157 | 158 | **Adapters**: 159 | 160 | - `cache` (e.g., `redis.go`): This likely represents the caching mechanism, an adapter to an external caching service like Redis. 161 | - `functions`: These are serverless functions for different operations (`delete`, `generate`, `notification`, `redirect`, `stats`), each with its own `bootstrap` and `main.go`. They serve as entry points for various operations, acting as adapters to external triggers or requests. 162 | - `handlers` (e.g., `delete.go`, `generate.go`): These are likely the controllers or use-case handlers that interact with the core domain logic. 163 | - `repository` (e.g., `link.go`, `stats.go`): Represents the data access layer, an adapter for database interaction. 164 | 165 | **Core Directory**: 166 | 167 | - **Domain** (e.g., `link.go`, `stats.go`): Contains the business logic and entities of your application. 168 | - **Ports** (e.g., `cache.go`, `link.go`): Interfaces that define the expected operations (like CRUD) for various entities. 169 | - **Services** (e.g., `link.go`, `stats.go`): Implements business logic or domain services. 170 | 171 | **Tests Directory**: 172 | 173 | - Contains various tests (`benchmark`, `unit`) to ensure that both the core logic and adapters work as expected. 174 | 175 | **Other Files**: 176 | 177 | - `go.mod`, `go.sum`: Go module files for managing dependencies. 178 | - `Makefile`, `README.md`: For building the project and documentation. 179 | - `samconfig.toml`, `template.yaml`: AWS SAM configuration files for deploying serverless functions. 180 | 181 | ### Serverless and Hexagonal Architecture: 182 | 183 | In a serverless context, Hexagonal Architecture brings several benefits: 184 | 185 | - **Decoupling**: Your core logic is separated from external services like databases, message queues, or web frameworks. 186 | - **Flexibility**: Easy to replace or modify external services without affecting the core logic. 187 | - **Scalability**: Serverless functions (like AWS Lambda) can scale automatically, handling varying loads efficiently. 188 | - **Cost-Effective**: Pay for the compute time you use, which can be more cost-effective for certain workloads. 189 | 190 | ## License 191 | 192 | This library is licensed under the MIT-0 License. See the LICENSE file. 193 | -------------------------------------------------------------------------------- /assets/class_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Furkan-Gulsen/golang-url-shortener/03d5641aabef6456e39f084fba370c656ff5d020/assets/class_diagram.png -------------------------------------------------------------------------------- /assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Furkan-Gulsen/golang-url-shortener/03d5641aabef6456e39f084fba370c656ff5d020/assets/cover.png -------------------------------------------------------------------------------- /assets/hexagonal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Furkan-Gulsen/golang-url-shortener/03d5641aabef6456e39f084fba370c656ff5d020/assets/hexagonal.png -------------------------------------------------------------------------------- /assets/system_design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Furkan-Gulsen/golang-url-shortener/03d5641aabef6456e39f084fba370c656ff5d020/assets/system_design.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Furkan-Gulsen/golang-url-shortener 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.41.0 7 | github.com/aws/aws-sdk-go v1.48.16 8 | github.com/aws/aws-sdk-go-v2/config v1.26.1 9 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.12 10 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.6 11 | github.com/go-redis/redis/v8 v8.11.5 12 | github.com/google/uuid v1.5.0 13 | github.com/joho/godotenv v1.5.1 14 | github.com/slack-go/slack v0.12.3 15 | github.com/stretchr/testify v1.7.5 16 | ) 17 | 18 | require ( 19 | github.com/aws/aws-sdk-go-v2 v1.24.0 // indirect 20 | github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect 21 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect 24 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.18.5 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.8.10 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect 29 | github.com/aws/aws-sdk-go-v2/service/sqs v1.29.6 // indirect 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect 33 | github.com/aws/smithy-go v1.19.0 // indirect 34 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 35 | github.com/davecgh/go-spew v1.1.1 // indirect 36 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 37 | github.com/gorilla/websocket v1.5.1 // indirect 38 | github.com/jmespath/go-jmespath v0.4.0 // indirect 39 | github.com/pmezard/go-difflib v1.0.0 // indirect 40 | golang.org/x/net v0.19.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-lambda-go v1.41.0 h1:l/5fyVb6Ud9uYd411xdHZzSf2n86TakxzpvIoz7l+3Y= 2 | github.com/aws/aws-lambda-go v1.41.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= 3 | github.com/aws/aws-sdk-go v1.48.16 h1:mcj2/9J/MJ55Dov+ocMevhR8Jv6jW/fAxbrn4a1JFc8= 4 | github.com/aws/aws-sdk-go v1.48.16/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 5 | github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= 6 | github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 7 | github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= 8 | github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= 9 | github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= 10 | github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= 11 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.12 h1:6p4l8wc8QMRSg8Yb6qfmiJpkfwyJtcljmGH6hcxz/ik= 12 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.12.12/go.mod h1:mzvoVQGD+ivawg984kcM2zd7oCFcknJ0uWTaR19lqEs= 13 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= 18 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= 19 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 20 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 21 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.6 h1:kSdpnPOZL9NG5QHoKL5rTsdY+J+77hr+vqVMsPeyNe0= 22 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.26.6/go.mod h1:o7TD9sjdgrl8l/g2a2IkYjuhxjPy9DMP2sWo7piaRBQ= 23 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.18.5 h1:ekyZDC/JMR4s/64oT9KsOnYWfGr03ebkwgHwe3iX9rA= 24 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.18.5/go.mod h1:T461RxBmf94zuOuIUifdy5Zim3DJTo0X4nXE3vodXQI= 25 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 26 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 27 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.8.10 h1:h8uweImUHGgyNKrxIUwpPs6XiH0a6DJ17hSJvFLgPAo= 28 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.8.10/go.mod h1:LZKVtMBiZfdvUWgwg61Qo6kyAmE5rn9Dw36AqnycvG8= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= 31 | github.com/aws/aws-sdk-go-v2/service/sqs v1.29.6 h1:UdbDTllc7cmusTTMy1dcTrYKRl4utDEsmKh9ZjvhJCc= 32 | github.com/aws/aws-sdk-go-v2/service/sqs v1.29.6/go.mod h1:mCUv04gd/7g+/HNzDB4X6dzJuygji0ckvB3Lg/TdG5Y= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= 39 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 40 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 41 | github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= 42 | github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 43 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 44 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 45 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 46 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 47 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 48 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 49 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 50 | github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= 51 | github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= 52 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 53 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 54 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 55 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 56 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 57 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= 58 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 59 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 60 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 61 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 62 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 63 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 64 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 65 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 66 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 67 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 68 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 69 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 70 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 71 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 72 | github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= 73 | github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= 74 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 75 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 76 | github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88= 77 | github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 80 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 81 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 82 | github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q= 83 | github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 84 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= 85 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 86 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 87 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 88 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 89 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 90 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 92 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 94 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 95 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 96 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 97 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 98 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 100 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 101 | -------------------------------------------------------------------------------- /internal/adapters/cache/redis.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-redis/redis/v8" 8 | ) 9 | 10 | type RedisCache struct { 11 | client *redis.Client 12 | } 13 | 14 | func NewRedisCache(address string, password string, db int) *RedisCache { 15 | client := redis.NewClient(&redis.Options{ 16 | Addr: address, 17 | Password: password, 18 | DB: db, 19 | }) 20 | 21 | return &RedisCache{client: client} 22 | } 23 | 24 | func (r *RedisCache) Set(ctx context.Context, key string, val string) error { 25 | return r.client.Set(ctx, key, val, time.Minute).Err() 26 | } 27 | 28 | func (r *RedisCache) Get(ctx context.Context, key string) (string, error) { 29 | val, err := r.client.Get(ctx, key).Result() 30 | if err == redis.Nil { 31 | return "", nil 32 | } 33 | return val, err 34 | } 35 | 36 | func (r *RedisCache) Delete(ctx context.Context, key string) error { 37 | return r.client.Del(ctx, key).Err() 38 | } 39 | -------------------------------------------------------------------------------- /internal/adapters/functions/delete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/repository" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/config" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/aws/aws-lambda-go/lambda" 12 | ) 13 | 14 | func main() { 15 | appConfig := config.NewConfig() 16 | redisAddress, redisPassword, redisDB := appConfig.GetRedisParams() 17 | linkTableName := appConfig.GetLinkTableName() 18 | statsTableName := appConfig.GetStatsTableName() 19 | 20 | cache := cache.NewRedisCache(redisAddress, redisPassword, redisDB) 21 | 22 | linkRepo := repository.NewLinkRepository(context.TODO(), linkTableName) 23 | statsRepo := repository.NewStatsRepository(context.TODO(), statsTableName) 24 | 25 | linkService := services.NewLinkService(linkRepo, cache) 26 | statsService := services.NewStatsService(statsRepo, cache) 27 | 28 | handler := handlers.NewDeleteFunctionHandler(linkService, statsService) 29 | 30 | lambda.Start(handler.Delete) 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapters/functions/generate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/repository" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/config" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/aws/aws-lambda-go/lambda" 12 | ) 13 | 14 | func main() { 15 | appConfig := config.NewConfig() 16 | redisAddress, redisPassword, redisDB := appConfig.GetRedisParams() 17 | cache := cache.NewRedisCache(redisAddress, redisPassword, redisDB) 18 | linkTableName := appConfig.GetLinkTableName() 19 | statsTableName := appConfig.GetStatsTableName() 20 | 21 | linkRepo := repository.NewLinkRepository(context.TODO(), linkTableName) 22 | linkService := services.NewLinkService(linkRepo, cache) 23 | 24 | statsRepo := repository.NewStatsRepository(context.TODO(), statsTableName) 25 | statsService := services.NewStatsService(statsRepo, cache) 26 | 27 | handler := handlers.NewGenerateLinkFunctionHandler(linkService, statsService) 28 | lambda.Start(handler.CreateShortLink) 29 | } 30 | -------------------------------------------------------------------------------- /internal/adapters/functions/notification/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 7 | "github.com/aws/aws-lambda-go/lambda" 8 | ) 9 | 10 | func main() { 11 | log.Print("Starting Lambda") 12 | lambda.Start(handlers.SlackHandler) 13 | } 14 | -------------------------------------------------------------------------------- /internal/adapters/functions/redirect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/repository" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/config" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/aws/aws-lambda-go/lambda" 12 | ) 13 | 14 | func main() { 15 | appConfig := config.NewConfig() 16 | redisAddress, redisPassword, redisDB := appConfig.GetRedisParams() 17 | cache := cache.NewRedisCache(redisAddress, redisPassword, redisDB) 18 | linkTableName := appConfig.GetLinkTableName() 19 | statsTableName := appConfig.GetStatsTableName() 20 | 21 | linkRepo := repository.NewLinkRepository(context.TODO(), linkTableName) 22 | linkService := services.NewLinkService(linkRepo, cache) 23 | 24 | statsRepo := repository.NewStatsRepository(context.TODO(), statsTableName) 25 | statsService := services.NewStatsService(statsRepo, cache) 26 | 27 | handler := handlers.NewRedirectFunctionHandler(linkService, statsService) 28 | 29 | lambda.Start(handler.Redirect) 30 | } 31 | -------------------------------------------------------------------------------- /internal/adapters/functions/stats/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/repository" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/config" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/aws/aws-lambda-go/lambda" 12 | ) 13 | 14 | func main() { 15 | appConfig := config.NewConfig() 16 | redisAddress, redisPassword, redisDB := appConfig.GetRedisParams() 17 | linkTableName := appConfig.GetLinkTableName() 18 | statsTableName := appConfig.GetStatsTableName() 19 | 20 | cache := cache.NewRedisCache(redisAddress, redisPassword, redisDB) 21 | 22 | linkRepo := repository.NewLinkRepository(context.TODO(), linkTableName) 23 | statsRepo := repository.NewStatsRepository(context.TODO(), statsTableName) 24 | 25 | linkService := services.NewLinkService(linkRepo, cache) 26 | statsService := services.NewStatsService(statsRepo, cache) 27 | 28 | handler := handlers.NewStatsFunctionHandler(linkService, statsService) 29 | 30 | lambda.Start(handler.Stats) 31 | } 32 | -------------------------------------------------------------------------------- /internal/adapters/handlers/delete.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 7 | "github.com/aws/aws-lambda-go/events" 8 | ) 9 | 10 | type DeleteFunctionHandler struct { 11 | statsService *services.StatsService 12 | linkService *services.LinkService 13 | } 14 | 15 | func NewDeleteFunctionHandler(l *services.LinkService, s *services.StatsService) *DeleteFunctionHandler { 16 | return &DeleteFunctionHandler{linkService: l, statsService: s} 17 | } 18 | 19 | func (s *DeleteFunctionHandler) Delete(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) { 20 | id := req.PathParameters["id"] 21 | 22 | err := s.linkService.Delete(ctx, id) 23 | if err != nil { 24 | return events.APIGatewayProxyResponse{StatusCode: 500}, err 25 | } 26 | 27 | err = s.statsService.Delete(ctx, id) 28 | if err != nil { 29 | return events.APIGatewayProxyResponse{StatusCode: 500}, err 30 | } 31 | 32 | return events.APIGatewayProxyResponse{StatusCode: 204}, nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/adapters/handlers/generate.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "math/big" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 15 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 16 | "github.com/aws/aws-lambda-go/events" 17 | "github.com/google/uuid" 18 | 19 | "github.com/aws/aws-sdk-go-v2/aws" 20 | "github.com/aws/aws-sdk-go-v2/config" 21 | "github.com/aws/aws-sdk-go-v2/service/sqs" 22 | ) 23 | 24 | type RequestBody struct { 25 | Long string `json:"long"` 26 | } 27 | type GenerateLinkFunctionHandler struct { 28 | linkService *services.LinkService 29 | statsService *services.StatsService 30 | } 31 | 32 | func NewGenerateLinkFunctionHandler(l *services.LinkService, s *services.StatsService) *GenerateLinkFunctionHandler { 33 | return &GenerateLinkFunctionHandler{linkService: l, statsService: s} 34 | } 35 | 36 | func (h *GenerateLinkFunctionHandler) CreateShortLink(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) { 37 | var requestBody RequestBody 38 | err := json.Unmarshal([]byte(req.Body), &requestBody) 39 | if err != nil { 40 | return ClientError(http.StatusBadRequest, "Invalid JSON") 41 | } 42 | 43 | if requestBody.Long == "" { 44 | return ClientError(http.StatusBadRequest, "URL cannot be empty") 45 | } 46 | if len(requestBody.Long) < 15 { 47 | return ClientError(http.StatusBadRequest, "URL must be at least 15 characters long") 48 | } 49 | if !IsValidLink(requestBody.Long) { 50 | return ClientError(http.StatusBadRequest, "Invalid URL format") 51 | } 52 | 53 | link := domain.Link{ 54 | Id: GenerateShortURLID(8), 55 | OriginalURL: requestBody.Long, 56 | CreatedAt: time.Now(), 57 | } 58 | 59 | err = h.linkService.Create(ctx, link) 60 | if err != nil { 61 | return ServerError(err) 62 | } 63 | 64 | js, err := json.Marshal(link) 65 | if err != nil { 66 | return ServerError(err) 67 | } 68 | 69 | err = h.statsService.Create(ctx, domain.Stats{ 70 | Id: uuid.NewString(), 71 | LinkID: link.Id, 72 | Platform: domain.PlatformTwitter, 73 | CreatedAt: time.Now(), 74 | }) 75 | if err != nil { 76 | log.Println("failed to create stats: ", err) 77 | } 78 | 79 | sendMessageToQueue(ctx, link) 80 | 81 | return events.APIGatewayProxyResponse{ 82 | StatusCode: http.StatusOK, 83 | Body: string(js), 84 | }, nil 85 | } 86 | 87 | func sendMessageToQueue(ctx context.Context, link domain.Link) { 88 | cfg, err := config.LoadDefaultConfig(ctx) 89 | if err != nil { 90 | log.Fatalf("unable to load SDK config, %v", err.Error()) 91 | return 92 | } 93 | 94 | sqsClient := sqs.NewFromConfig(cfg) 95 | queueUrl := os.Getenv("QueueUrl") 96 | 97 | if queueUrl == "" { 98 | log.Println("QueueUrl is not set") 99 | return 100 | } 101 | 102 | _, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{ 103 | QueueUrl: &queueUrl, 104 | MessageBody: aws.String("The system generated a short URL with the ID " + link.Id), 105 | }) 106 | 107 | if err != nil { 108 | fmt.Printf("Failed to send message to SQS, %v", err.Error()) 109 | } 110 | } 111 | 112 | func GenerateShortURLID(length int) string { 113 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 114 | result := make([]byte, length) 115 | for i := 0; i < length; i++ { 116 | charIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) 117 | result[i] = charset[charIndex.Int64()] 118 | } 119 | return string(result) 120 | } 121 | -------------------------------------------------------------------------------- /internal/adapters/handlers/helpers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "regexp" 7 | 8 | "github.com/aws/aws-lambda-go/events" 9 | ) 10 | 11 | func ClientError(status int, message string) (events.APIGatewayProxyResponse, error) { 12 | return events.APIGatewayProxyResponse{ 13 | StatusCode: status, 14 | Body: message, 15 | }, nil 16 | } 17 | 18 | func ServerError(err error) (events.APIGatewayProxyResponse, error) { 19 | return events.APIGatewayProxyResponse{ 20 | StatusCode: http.StatusInternalServerError, 21 | Body: err.Error(), 22 | }, nil 23 | } 24 | 25 | func IsValidLink(u string) bool { 26 | re := regexp.MustCompile(`^(http|https)://`) 27 | if !re.MatchString(u) { 28 | return false 29 | } 30 | 31 | parsedURL, err := url.ParseRequestURI(u) 32 | if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { 33 | return false 34 | } 35 | 36 | return true 37 | } 38 | -------------------------------------------------------------------------------- /internal/adapters/handlers/redirect.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | type RedirectFunctionHandler struct { 16 | linkService *services.LinkService 17 | statsService *services.StatsService 18 | } 19 | 20 | func NewRedirectFunctionHandler(l *services.LinkService, s *services.StatsService) *RedirectFunctionHandler { 21 | return &RedirectFunctionHandler{linkService: l, statsService: s} 22 | } 23 | 24 | func (h *RedirectFunctionHandler) Redirect(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) { 25 | pathSegments := strings.Split(req.RawPath, "/") 26 | if len(pathSegments) < 2 { 27 | return ClientError(http.StatusBadRequest, "Invalid URL path") 28 | } 29 | 30 | shortLinkKey := pathSegments[len(pathSegments)-1] 31 | longLink, err := h.linkService.GetOriginalURL(ctx, shortLinkKey) 32 | if err != nil || *longLink == "" { 33 | return ClientError(http.StatusNotFound, "Link not found") 34 | } 35 | 36 | if err := h.statsService.Create(ctx, domain.Stats{ 37 | Id: uuid.NewString(), 38 | LinkID: shortLinkKey, 39 | CreatedAt: time.Now(), 40 | Platform: domain.PlatformTwitter, // * TODO: Get platform from request 41 | }); err != nil { 42 | return ServerError(err) 43 | } 44 | 45 | return events.APIGatewayProxyResponse{ 46 | StatusCode: http.StatusMovedPermanently, 47 | Headers: map[string]string{ 48 | "Location": *longLink, 49 | }, 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/adapters/handlers/slack.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/config" 10 | "github.com/aws/aws-lambda-go/events" 11 | "github.com/slack-go/slack" 12 | ) 13 | 14 | func PostMessageToSlack(ctx context.Context, message string) error { 15 | appConfig := config.NewConfig() 16 | slackToken, slackChannelID := appConfig.GetSlackParams() 17 | 18 | api := slack.New(slackToken) 19 | channelID, timestamp, err := api.PostMessage( 20 | slackChannelID, 21 | slack.MsgOptionText(message, false), 22 | ) 23 | if err != nil { 24 | log.Printf("Error posting to Slack: %s", err) 25 | return err 26 | } 27 | log.Printf("Message successfully sent to Slack channel %s at %s", channelID, timestamp) 28 | return nil 29 | } 30 | 31 | func HandleAPIGatewayRequest(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) { 32 | err := PostMessageToSlack(ctx, "Hello world! API Gateway message.") 33 | if err != nil { 34 | return events.APIGatewayProxyResponse{StatusCode: 500}, err 35 | } 36 | return events.APIGatewayProxyResponse{ 37 | StatusCode: 200, 38 | Body: "Message successfully sent to Slack", 39 | }, nil 40 | } 41 | 42 | func HandleSQSMessage(ctx context.Context, message events.SQSMessage) error { 43 | return PostMessageToSlack(ctx, message.Body) 44 | } 45 | 46 | func SlackHandler(ctx context.Context, event json.RawMessage) error { 47 | var sqsEvent events.SQSEvent 48 | if err := json.Unmarshal(event, &sqsEvent); err == nil && len(sqsEvent.Records) > 0 { 49 | for _, message := range sqsEvent.Records { 50 | err := PostMessageToSlack(ctx, message.Body) 51 | if err != nil { 52 | log.Printf("Error handling SQS message (ID: %s): %v", message.MessageId, err) 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | var apiEvent events.APIGatewayV2HTTPRequest 59 | log.Print("apiEvent: ", apiEvent) 60 | if err := json.Unmarshal(event, &apiEvent); err == nil && apiEvent.RequestContext.HTTP.Method != "" { 61 | _, err := HandleAPIGatewayRequest(ctx, apiEvent) 62 | return err 63 | } 64 | 65 | return fmt.Errorf("invalid event type") 66 | } 67 | -------------------------------------------------------------------------------- /internal/adapters/handlers/stats.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 9 | "github.com/aws/aws-lambda-go/events" 10 | ) 11 | 12 | type StatsFunctionHandler struct { 13 | statsService *services.StatsService 14 | linkService *services.LinkService 15 | } 16 | 17 | func NewStatsFunctionHandler(l *services.LinkService, s *services.StatsService) *StatsFunctionHandler { 18 | return &StatsFunctionHandler{linkService: l, statsService: s} 19 | } 20 | 21 | func (s *StatsFunctionHandler) Stats(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayProxyResponse, error) { 22 | links, err := s.linkService.GetAll(ctx) 23 | if err != nil { 24 | return ServerError(err) 25 | } 26 | 27 | for i, link := range links { 28 | stats, err := s.statsService.GetStatsByLinkID(ctx, link.Id) 29 | if err != nil { 30 | log.Printf("Error getting stats for link '%s': %v", link.Id, err) 31 | continue 32 | } 33 | links[i].Stats = stats 34 | } 35 | 36 | jsonResponse, err := json.Marshal(links) 37 | if err != nil { 38 | return ServerError(err) 39 | } 40 | 41 | return events.APIGatewayProxyResponse{ 42 | StatusCode: 200, 43 | Body: string(jsonResponse), 44 | }, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/adapters/repository/link.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 11 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 12 | ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 13 | "github.com/aws/aws-sdk-go/aws" 14 | ) 15 | 16 | type LinkRepository struct { 17 | client *dynamodb.Client 18 | tableName string 19 | } 20 | 21 | func NewLinkRepository(ctx context.Context, tableName string) *LinkRepository { 22 | cfg, err := config.LoadDefaultConfig(ctx) 23 | if err != nil { 24 | log.Fatalf("unable to load SDK config, %v", err) 25 | } 26 | 27 | client := dynamodb.NewFromConfig(cfg) 28 | return &LinkRepository{ 29 | client: client, 30 | tableName: tableName, 31 | } 32 | } 33 | 34 | func (d *LinkRepository) All(ctx context.Context) ([]domain.Link, error) { 35 | var links []domain.Link 36 | 37 | input := &dynamodb.ScanInput{ 38 | TableName: &d.tableName, 39 | Limit: aws.Int32(20), 40 | } 41 | 42 | result, err := d.client.Scan(ctx, input) 43 | 44 | if err != nil { 45 | return links, fmt.Errorf("failed to get items from DynamoDB: %w", err) 46 | } 47 | 48 | err = attributevalue.UnmarshalListOfMaps(result.Items, &links) 49 | if err != nil { 50 | return links, fmt.Errorf("failed to unmarshal data from DynamoDB: %w", err) 51 | } 52 | 53 | return links, nil 54 | } 55 | 56 | func (d *LinkRepository) Get(ctx context.Context, id string) (domain.Link, error) { 57 | link := domain.Link{} 58 | 59 | input := &dynamodb.GetItemInput{ 60 | TableName: &d.tableName, 61 | Key: map[string]ddbtypes.AttributeValue{ 62 | "id": &ddbtypes.AttributeValueMemberS{Value: id}, 63 | }, 64 | } 65 | 66 | result, err := d.client.GetItem(ctx, input) 67 | if err != nil { 68 | return link, fmt.Errorf("failed to get item from DynamoDB: %w", err) 69 | } 70 | 71 | err = attributevalue.UnmarshalMap(result.Item, &link) 72 | if err != nil { 73 | return link, fmt.Errorf("failed to unmarshal data from DynamoDB: %w", err) 74 | } 75 | 76 | return link, nil 77 | } 78 | 79 | func (d *LinkRepository) Create(ctx context.Context, link domain.Link) error { 80 | item, err := attributevalue.MarshalMap(link) 81 | if err != nil { 82 | return fmt.Errorf("failed to marshal data: %w", err) 83 | } 84 | 85 | input := &dynamodb.PutItemInput{ 86 | TableName: &d.tableName, 87 | Item: item, 88 | } 89 | 90 | _, err = d.client.PutItem(ctx, input) 91 | if err != nil { 92 | return fmt.Errorf("failed to put item to DynamoDB: %w", err) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (d *LinkRepository) Delete(ctx context.Context, id string) error { 99 | input := &dynamodb.DeleteItemInput{ 100 | TableName: &d.tableName, 101 | Key: map[string]ddbtypes.AttributeValue{ 102 | "id": &ddbtypes.AttributeValueMemberS{Value: id}, 103 | }, 104 | } 105 | 106 | _, err := d.client.DeleteItem(ctx, input) 107 | if err != nil { 108 | return fmt.Errorf("failed to delete item from DynamoDB: %w", err) 109 | } 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/adapters/repository/stats.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 11 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 12 | ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 13 | "github.com/aws/aws-sdk-go/aws" 14 | ) 15 | 16 | type StatsRepository struct { 17 | client *dynamodb.Client 18 | tableName string 19 | } 20 | 21 | func NewStatsRepository(ctx context.Context, tableName string) *StatsRepository { 22 | cfg, err := config.LoadDefaultConfig(ctx) 23 | if err != nil { 24 | log.Fatalf("unable to load SDK config, %v", err) 25 | } 26 | 27 | client := dynamodb.NewFromConfig(cfg) 28 | return &StatsRepository{ 29 | client: client, 30 | tableName: tableName, 31 | } 32 | } 33 | 34 | func (d *StatsRepository) Get(ctx context.Context, id string) (domain.Stats, error) { 35 | input := &dynamodb.GetItemInput{ 36 | TableName: &d.tableName, 37 | Key: map[string]ddbtypes.AttributeValue{ 38 | "id": &ddbtypes.AttributeValueMemberS{Value: id}, 39 | }, 40 | } 41 | 42 | result, err := d.client.GetItem(ctx, input) 43 | if err != nil { 44 | return domain.Stats{}, fmt.Errorf("failed to get item from DynamoDB: %w", err) 45 | } 46 | 47 | stats := domain.Stats{} 48 | err = attributevalue.UnmarshalMap(result.Item, &stats) 49 | if err != nil { 50 | return domain.Stats{}, fmt.Errorf("failed to unmarshal data: %w", err) 51 | } 52 | 53 | return stats, nil 54 | } 55 | 56 | func (d *StatsRepository) All(ctx context.Context) ([]domain.Stats, error) { 57 | input := &dynamodb.ScanInput{ 58 | TableName: &d.tableName, 59 | } 60 | 61 | result, err := d.client.Scan(ctx, input) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to scan table: %w", err) 64 | } 65 | 66 | stats := []domain.Stats{} 67 | err = attributevalue.UnmarshalListOfMaps(result.Items, &stats) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to unmarshal data: %w", err) 70 | } 71 | 72 | return stats, nil 73 | } 74 | 75 | func (d *StatsRepository) Create(ctx context.Context, stats domain.Stats) error { 76 | item, err := attributevalue.MarshalMap(stats) 77 | if err != nil { 78 | return fmt.Errorf("failed to marshal data: %w", err) 79 | } 80 | 81 | input := &dynamodb.PutItemInput{ 82 | TableName: &d.tableName, 83 | Item: item, 84 | } 85 | 86 | _, err = d.client.PutItem(ctx, input) 87 | if err != nil { 88 | return fmt.Errorf("failed to put item to DynamoDB: %w", err) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (d *StatsRepository) Delete(ctx context.Context, id string) error { 95 | input := &dynamodb.DeleteItemInput{ 96 | TableName: &d.tableName, 97 | Key: map[string]ddbtypes.AttributeValue{ 98 | "id": &ddbtypes.AttributeValueMemberS{Value: id}, 99 | }, 100 | } 101 | 102 | _, err := d.client.DeleteItem(ctx, input) 103 | if err != nil { 104 | return fmt.Errorf("failed to delete item from DynamoDB: %w", err) 105 | } 106 | return nil 107 | } 108 | 109 | func (d *StatsRepository) GetStatsByLinkID(ctx context.Context, linkID string) ([]domain.Stats, error) { 110 | input := &dynamodb.ScanInput{ 111 | TableName: &d.tableName, 112 | ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ 113 | ":linkID": &ddbtypes.AttributeValueMemberS{Value: linkID}, 114 | }, 115 | FilterExpression: aws.String("link_id = :linkID"), 116 | } 117 | 118 | result, err := d.client.Scan(ctx, input) 119 | if err != nil { 120 | return nil, fmt.Errorf("failed to scan table: %w", err) 121 | } 122 | 123 | stats := []domain.Stats{} 124 | err = attributevalue.UnmarshalListOfMaps(result.Items, &stats) 125 | if err != nil { 126 | return nil, fmt.Errorf("failed to unmarshal data: %w", err) 127 | } 128 | 129 | return stats, nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/joho/godotenv" 10 | ) 11 | 12 | type AppConfig struct { 13 | dynamoTableName string // DynamoDB table name 14 | redisAddress string // Redis address 15 | redisPassword string // Redis password 16 | redisDB int // Redis DB 17 | slackToken string // Slack token 18 | slackChannelID string // Slack channel ID 19 | } 20 | 21 | func NewConfig() *AppConfig { 22 | return &AppConfig{ 23 | dynamoTableName: "UrlShortenerTable", // default value 24 | redisAddress: "localhost:6379", // default value 25 | redisPassword: "", // default value 26 | redisDB: 0, // default value 27 | slackToken: "", // default value 28 | slackChannelID: "", // default value 29 | } 30 | } 31 | 32 | func init() { 33 | err := godotenv.Load(".env") 34 | if err != nil { 35 | log.Print("Error loading .env file: ", err) 36 | } 37 | } 38 | 39 | func (c *AppConfig) GetSlackParams() (string, string) { 40 | slackToken, tokenOK := os.LookupEnv("SlackToken") 41 | slackChannelID, channelOK := os.LookupEnv("SlackChannelID") 42 | if !tokenOK || !channelOK { 43 | return os.Getenv("SlackToken"), os.Getenv("SlackChannelID") 44 | } 45 | return slackToken, slackChannelID 46 | } 47 | 48 | func (c *AppConfig) GetLinkTableName() string { 49 | tableName, ok := os.LookupEnv("LinkTableName") 50 | if !ok { 51 | fmt.Println("Need LinkTableName environment variable") 52 | return os.Getenv("LinkTableName") 53 | } 54 | return tableName 55 | } 56 | 57 | func (c *AppConfig) GetStatsTableName() string { 58 | tableName, ok := os.LookupEnv("StastTableName") 59 | if !ok { 60 | fmt.Println("Need STATS_TABLE environment variable") 61 | return os.Getenv("StastTableName") 62 | } 63 | return tableName 64 | } 65 | 66 | func (c *AppConfig) GetRedisParams() (string, string, int) { 67 | address, ok := os.LookupEnv("RedisAddress") 68 | if !ok { 69 | fmt.Println("Need RedisAddress environment variable") 70 | return c.redisAddress, c.redisPassword, c.redisDB 71 | } 72 | 73 | password, ok := os.LookupEnv("RedisPassword") 74 | if !ok { 75 | fmt.Println("Need RedisPassword environment variable") 76 | return address, c.redisPassword, c.redisDB 77 | } 78 | 79 | dbStr, ok := os.LookupEnv("RedisDB") 80 | if !ok { 81 | fmt.Println("Need RedisDB environment variable") 82 | return address, password, c.redisDB 83 | } 84 | 85 | db, err := strconv.Atoi(dbStr) 86 | if err != nil { 87 | fmt.Printf("RedisDB environment variable is not a valid integer: %v\n", err) 88 | return address, password, c.redisDB 89 | } 90 | 91 | return address, password, db 92 | } 93 | -------------------------------------------------------------------------------- /internal/core/domain/link.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type Link struct { 6 | Id string `dynamodbav:"id" json:"id"` 7 | OriginalURL string `dynamodbav:"original_url" json:"original_url"` 8 | CreatedAt time.Time `dynamodbav:"created_at" json:"created_at"` 9 | Stats []Stats `dynamodbav:"-" json:"stats"` 10 | } 11 | -------------------------------------------------------------------------------- /internal/core/domain/stats.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "time" 4 | 5 | type Platform int 6 | 7 | const ( 8 | PlatformUnknown Platform = iota 9 | PlatformInstagram 10 | PlatformTwitter 11 | PlatformYouTube 12 | ) 13 | 14 | func (p Platform) String() string { 15 | switch p { 16 | case PlatformInstagram: 17 | return "Instagram" 18 | case PlatformTwitter: 19 | return "Twitter" 20 | case PlatformYouTube: 21 | return "YouTube" 22 | default: 23 | return "Unknown" 24 | } 25 | } 26 | 27 | type Stats struct { 28 | Id string `dynamodbav:"id" json:"id"` 29 | Platform Platform `dynamodbav:"platform" json:"platform"` 30 | LinkID string `dynamodbav:"link_id" json:"link_id"` 31 | CreatedAt time.Time `dynamodbav:"created_at" json:"created_at"` 32 | } 33 | -------------------------------------------------------------------------------- /internal/core/ports/cache.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import "context" 4 | 5 | type Cache interface { 6 | Set(context.Context, string, string) error 7 | Get(context.Context, string) (string, error) 8 | Delete(context.Context, string) error 9 | } 10 | -------------------------------------------------------------------------------- /internal/core/ports/link.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 7 | ) 8 | 9 | type LinkPort interface { 10 | All(context.Context) ([]domain.Link, error) 11 | Get(context.Context, string) (domain.Link, error) 12 | Create(context.Context, domain.Link) error 13 | Delete(context.Context, string) error 14 | } 15 | -------------------------------------------------------------------------------- /internal/core/ports/statistics.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 7 | ) 8 | 9 | type StatsPort interface { 10 | All(context.Context) ([]domain.Stats, error) 11 | Get(context.Context, string) (domain.Stats, error) 12 | Create(context.Context, domain.Stats) error 13 | Delete(context.Context, string) error 14 | GetStatsByLinkID(context.Context, string) ([]domain.Stats, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/core/services/link.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/ports" 9 | ) 10 | 11 | type LinkService struct { 12 | port ports.LinkPort 13 | cache ports.Cache 14 | } 15 | 16 | func NewLinkService(p ports.LinkPort, c ports.Cache) *LinkService { 17 | return &LinkService{port: p, cache: c} 18 | } 19 | 20 | func (service *LinkService) GetAll(ctx context.Context) ([]domain.Link, error) { 21 | links, err := service.port.All(ctx) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to get all links: %w", err) 24 | } 25 | return links, nil 26 | } 27 | 28 | func (service *LinkService) GetOriginalURL(ctx context.Context, shortLinkKey string) (*string, error) { 29 | // link, err := service.cache.Get(ctx, shortLinkKey) 30 | data, err := service.port.Get(ctx, shortLinkKey) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to get short URL for identifier '%s': %w", shortLinkKey, err) 33 | } 34 | return &data.OriginalURL, nil 35 | } 36 | 37 | func (service *LinkService) Create(ctx context.Context, link domain.Link) error { 38 | // if err := service.cache.Set(ctx, link.Id, link.OriginalURL); err != nil { 39 | // return fmt.Errorf("failed to set short URL for identifier '%s': %w", link.Id, err) 40 | // } 41 | if err := service.port.Create(ctx, link); err != nil { 42 | return fmt.Errorf("failed to create short URL: %w", err) 43 | } 44 | return nil 45 | } 46 | 47 | func (service *LinkService) Delete(ctx context.Context, short string) error { 48 | if err := service.port.Delete(ctx, short); err != nil { 49 | return fmt.Errorf("failed to delete short URL for identifier '%s': %w", short, err) 50 | } 51 | // if err := service.cache.Delete(ctx, short); err != nil { 52 | // return fmt.Errorf("failed to delete short URL for identifier '%s': %w", short, err) 53 | // } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/core/services/stats.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/ports" 9 | ) 10 | 11 | type StatsService struct { 12 | port ports.StatsPort 13 | cache ports.Cache 14 | } 15 | 16 | func NewStatsService(p ports.StatsPort, c ports.Cache) *StatsService { 17 | return &StatsService{port: p, cache: c} 18 | } 19 | 20 | func (service *StatsService) All(ctx context.Context) ([]domain.Stats, error) { 21 | stats, err := service.port.All(ctx) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to get all stats: %w", err) 24 | } 25 | return stats, nil 26 | } 27 | 28 | func (service *StatsService) Get(ctx context.Context, statsID string) (domain.Stats, error) { 29 | stats, err := service.port.Get(ctx, statsID) 30 | if err != nil { 31 | return domain.Stats{}, fmt.Errorf("failed to get stats for identifier '%s': %w", statsID, err) 32 | } 33 | return stats, nil 34 | } 35 | 36 | func (service *StatsService) Delete(ctx context.Context, linkID string) error { 37 | if err := service.port.Delete(ctx, linkID); err != nil { 38 | return fmt.Errorf("failed to delete stats for identifier '%s': %w", linkID, err) 39 | } 40 | return nil 41 | } 42 | 43 | func (service *StatsService) Create(ctx context.Context, data domain.Stats) error { 44 | if err := service.port.Create(ctx, data); err != nil { 45 | return fmt.Errorf("failed to create stats: %w", err) 46 | } 47 | return nil 48 | } 49 | 50 | func (service *StatsService) GetStatsByLinkID(ctx context.Context, linkID string) ([]domain.Stats, error) { 51 | stats, err := service.port.GetStatsByLinkID(ctx, linkID) 52 | if err != nil { 53 | return []domain.Stats{}, fmt.Errorf("failed to get stats for identifier '%s': %w", linkID, err) 54 | } 55 | return stats, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/tests/benchmark/link_benchmark_test.go: -------------------------------------------------------------------------------- 1 | package benchmark 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 11 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/tests/mock" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | func GetService() *services.LinkService { 16 | cache := cache.NewRedisCache("localhost:6379", "", 0) 17 | mockLinkRepo := mock.NewMockLinkRepo() 18 | 19 | linkService := services.NewLinkService(mockLinkRepo, cache) 20 | 21 | return linkService 22 | } 23 | 24 | func BenchmarkLinkServiceGetAll(b *testing.B) { 25 | service := GetService() 26 | ctx := context.Background() 27 | 28 | b.ResetTimer() 29 | for i := 0; i < b.N; i++ { 30 | _, err := service.GetAll(ctx) 31 | if err != nil { 32 | b.Fatalf("Benchmark GetAll failed: %v", err) 33 | } 34 | } 35 | } 36 | 37 | func BenchmarkLinkServiceGetOriginalURL(b *testing.B) { 38 | service := GetService() 39 | ctx := context.Background() 40 | shortLinkKey := "testid2" 41 | 42 | b.ResetTimer() 43 | for i := 0; i < b.N; i++ { 44 | _, err := service.GetOriginalURL(ctx, shortLinkKey) 45 | if err != nil { 46 | b.Fatalf("Benchmark GetOriginalURL failed: %v", err) 47 | } 48 | } 49 | } 50 | 51 | // Benchmark Create 52 | func BenchmarkLinkServiceCreate(b *testing.B) { 53 | service := GetService() 54 | ctx := context.Background() 55 | 56 | b.ResetTimer() 57 | for i := 0; i < b.N; i++ { 58 | newID := uuid.New().String() 59 | link := domain.Link{ 60 | Id: newID, 61 | OriginalURL: "https://example.com/" + newID, 62 | CreatedAt: time.Now(), 63 | } 64 | 65 | err := service.Create(ctx, link) 66 | if err != nil { 67 | b.Fatalf("Benchmark Create failed: %v", err) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/tests/mock/data.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 4 | 5 | var MockLinkData []domain.Link = []domain.Link{ 6 | {Id: "testid1", OriginalURL: "https://example.com/link1"}, 7 | {Id: "testid2", OriginalURL: "https://example.com/link2"}, 8 | {Id: "testid3", OriginalURL: "https://example.com/link3"}, 9 | } 10 | 11 | var MockStatsData []domain.Stats = []domain.Stats{ 12 | {Id: "abcdefg1", Platform: domain.PlatformUnknown, LinkID: "testid1"}, 13 | {Id: "abcdefg2", Platform: domain.PlatformInstagram, LinkID: "testid2"}, 14 | {Id: "abcdefg3", Platform: domain.PlatformTwitter, LinkID: "testid3"}, 15 | } 16 | -------------------------------------------------------------------------------- /internal/tests/mock/mock_cache.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | type MockRedisCache struct { 10 | Store map[string]string 11 | TTL map[string]time.Time 12 | } 13 | 14 | func NewMockRedisCache() *MockRedisCache { 15 | return &MockRedisCache{ 16 | Store: make(map[string]string), 17 | TTL: make(map[string]time.Time), 18 | } 19 | } 20 | 21 | func (m *MockRedisCache) Set(ctx context.Context, key string, val string) error { 22 | m.Store[key] = val 23 | m.TTL[key] = time.Now().Add(time.Minute) 24 | return nil 25 | } 26 | 27 | func (m *MockRedisCache) Get(ctx context.Context, key string) (string, error) { 28 | val, ok := m.Store[key] 29 | if !ok { 30 | return "", errors.New("key not found") 31 | } 32 | if time.Now().After(m.TTL[key]) { 33 | delete(m.Store, key) 34 | delete(m.TTL, key) 35 | return "", errors.New("key expired") 36 | } 37 | return val, nil 38 | } 39 | 40 | func (m *MockRedisCache) Delete(ctx context.Context, key string) error { 41 | _, ok := m.Store[key] 42 | if !ok { 43 | return errors.New("key not found") 44 | } 45 | delete(m.Store, key) 46 | delete(m.TTL, key) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/tests/mock/mock_link.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 7 | ) 8 | 9 | type MockLinkRepo struct { 10 | Links []domain.Link 11 | Stats []domain.Stats 12 | } 13 | 14 | func NewMockLinkRepo() *MockLinkRepo { 15 | return &MockLinkRepo{ 16 | Links: MockLinkData, 17 | Stats: MockStatsData, 18 | } 19 | } 20 | 21 | func (m *MockLinkRepo) All(ctx context.Context) ([]domain.Link, error) { 22 | return m.Links, nil 23 | } 24 | 25 | func (m *MockLinkRepo) Get(ctx context.Context, id string) (domain.Link, error) { 26 | for _, link := range m.Links { 27 | if link.Id == id { 28 | return link, nil 29 | } 30 | } 31 | 32 | return domain.Link{}, nil 33 | } 34 | 35 | func (m *MockLinkRepo) Create(ctx context.Context, link domain.Link) error { 36 | m.Links = append(m.Links, link) 37 | return nil 38 | } 39 | 40 | func (m *MockLinkRepo) Delete(ctx context.Context, id string) error { 41 | for i, link := range m.Links { 42 | if link.Id == id { 43 | m.Links = append(m.Links[:i], m.Links[i+1:]...) 44 | return nil 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/tests/mock/mock_stats.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 7 | ) 8 | 9 | type MockStatsRepo struct { 10 | Stats []domain.Stats 11 | } 12 | 13 | func NewMockStatsRepo() *MockStatsRepo { 14 | return &MockStatsRepo{ 15 | Stats: MockStatsData, 16 | } 17 | } 18 | 19 | func (m *MockStatsRepo) Get(ctx context.Context, id string) (domain.Stats, error) { 20 | for _, stats := range m.Stats { 21 | if stats.Id == id { 22 | return stats, nil 23 | } 24 | } 25 | return domain.Stats{}, nil 26 | } 27 | 28 | func (m *MockStatsRepo) All(ctx context.Context) ([]domain.Stats, error) { 29 | return m.Stats, nil 30 | } 31 | 32 | func (m *MockStatsRepo) Create(ctx context.Context, stats domain.Stats) error { 33 | m.Stats = append(m.Stats, stats) 34 | return nil 35 | } 36 | 37 | func (m *MockStatsRepo) Delete(ctx context.Context, id string) error { 38 | for i, stats := range m.Stats { 39 | if stats.Id == id { 40 | m.Stats = append(m.Stats[:i], m.Stats[i+1:]...) 41 | return nil 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (m *MockStatsRepo) GetStatsByLinkID(ctx context.Context, linkID string) ([]domain.Stats, error) { 49 | var stats []domain.Stats 50 | for _, stat := range m.Stats { 51 | if stat.LinkID == linkID { 52 | stats = append(stats, stat) 53 | } 54 | } 55 | return stats, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/tests/unit/generate_link_test.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/tests/mock" 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestGenerateLinkUnit(t *testing.T) { 16 | mockLinkRepo := mock.NewMockLinkRepo() 17 | mockStats := mock.NewMockStatsRepo() 18 | cache := cache.NewRedisCache("localhost:6379", "", 0) 19 | FillCache(cache, mockLinkRepo.Links) 20 | linkService := services.NewLinkService(mockLinkRepo, cache) 21 | statsService := services.NewStatsService(mockStats, cache) 22 | apiHandler := handlers.NewGenerateLinkFunctionHandler(linkService, statsService) 23 | 24 | tests := []struct { 25 | longURL string 26 | expectedStatusCode int 27 | expectedBody string 28 | }{ 29 | { 30 | longURL: "https://example.com/link1", 31 | expectedStatusCode: 200, 32 | expectedBody: "", 33 | }, 34 | { 35 | longURL: "", 36 | expectedStatusCode: 400, 37 | expectedBody: "URL cannot be empty", 38 | }, 39 | { 40 | longURL: "invalid", 41 | expectedStatusCode: 400, 42 | expectedBody: "URL must be at least 15 characters long", 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.longURL, func(t *testing.T) { 48 | body := `{"long": "` + tt.longURL + `"}` 49 | request := events.APIGatewayV2HTTPRequest{Body: body} 50 | response, err := apiHandler.CreateShortLink(context.Background(), request) 51 | 52 | assert.NoError(t, err) 53 | assert.Equal(t, tt.expectedStatusCode, response.StatusCode) 54 | 55 | if tt.expectedStatusCode != 200 { 56 | assert.Equal(t, tt.expectedBody, response.Body) 57 | } 58 | }) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /internal/tests/unit/helpers.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 8 | ) 9 | 10 | func FillCache(cache *cache.RedisCache, links []domain.Link) error { 11 | for _, link := range links { 12 | err := cache.Set(context.Background(), link.Id, link.OriginalURL) 13 | if err != nil { 14 | return err 15 | } 16 | } 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/tests/unit/redirect_link_test.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/tests/mock" 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestRedirectLinkUnit(t *testing.T) { 16 | mockLinkRepo := mock.NewMockLinkRepo() 17 | cache := cache.NewRedisCache("localhost:6379", "", 0) 18 | FillCache(cache, mockLinkRepo.Links) 19 | linkService := services.NewLinkService(mockLinkRepo, cache) 20 | statsService := services.NewStatsService(mock.NewMockStatsRepo(), cache) 21 | apiHandler := handlers.NewRedirectFunctionHandler(linkService, statsService) 22 | 23 | tests := []struct { 24 | shortLink string 25 | expectStatusCode int 26 | expectLocation string 27 | expectBody string 28 | }{ 29 | { 30 | shortLink: "testid1", 31 | expectStatusCode: 301, 32 | expectLocation: "https://example.com/link1", 33 | expectBody: "", 34 | }, 35 | { 36 | shortLink: "testid2", 37 | expectStatusCode: 301, 38 | expectLocation: "https://example.com/link2", 39 | expectBody: "", 40 | }, 41 | { 42 | shortLink: "testid3", 43 | expectStatusCode: 301, 44 | expectLocation: "https://example.com/link3", 45 | expectBody: "", 46 | }, 47 | { 48 | shortLink: "nonexistentid", 49 | expectStatusCode: 404, 50 | expectLocation: "", 51 | expectBody: "Link not found", 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.shortLink, func(t *testing.T) { 57 | request := events.APIGatewayV2HTTPRequest{ 58 | RawPath: "/" + tt.shortLink, 59 | } 60 | 61 | response, err := apiHandler.Redirect(context.Background(), request) 62 | 63 | assert.NoError(t, err) 64 | assert.Equal(t, tt.expectStatusCode, response.StatusCode) 65 | 66 | location := response.Headers["Location"] 67 | assert.Equal(t, tt.expectLocation, location) 68 | 69 | if tt.expectStatusCode == 404 { 70 | assert.Equal(t, tt.expectBody, response.Body) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/tests/unit/slack_test.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSlack(t *testing.T) { 12 | 13 | t.Run("Send Message to Slack", func(t *testing.T) { 14 | err := handlers.PostMessageToSlack(context.Background(), "Hello world! API Gateway message.") 15 | assert.Nil(t, err) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/tests/unit/stats_test.go: -------------------------------------------------------------------------------- 1 | package unit 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/cache" 9 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/adapters/handlers" 10 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/domain" 11 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/core/services" 12 | "github.com/Furkan-Gulsen/golang-url-shortener/internal/tests/mock" 13 | "github.com/aws/aws-lambda-go/events" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestStatsTest(t *testing.T) { 18 | mockStatsRepo := mock.NewMockStatsRepo() 19 | cache := cache.NewRedisCache("localhost:6379", "", 0) 20 | statsService := services.NewStatsService(mockStatsRepo, cache) 21 | 22 | mockLinkRepo := mock.NewMockLinkRepo() 23 | linkService := services.NewLinkService(mockLinkRepo, cache) 24 | 25 | apiHander := handlers.NewStatsFunctionHandler(linkService, statsService) 26 | 27 | t.Run("Stats Unit Test", func(t *testing.T) { 28 | request := events.APIGatewayV2HTTPRequest{ 29 | RawPath: "/stats", 30 | } 31 | 32 | response, err := apiHander.Stats(context.Background(), request) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | var links []domain.Link 38 | err = json.Unmarshal([]byte(response.Body), &links) 39 | 40 | assert.Nil(t, err) 41 | assert.Equal(t, response.StatusCode, 200) 42 | assert.Equal(t, len(links), 3) 43 | }) 44 | 45 | } 46 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Globals: 4 | Function: 5 | MemorySize: 128 6 | Architectures: 7 | - arm64 8 | Handler: bootstrap 9 | Runtime: provided.al2 10 | Timeout: 5 11 | Tracing: Active 12 | 13 | Parameters: 14 | SlackToken: 15 | Type: String 16 | Description: Slack Token for notifications 17 | Default: '' 18 | SlackChannelID: 19 | Type: String 20 | Description: Slack Channel ID for notifications 21 | Default: '' 22 | LinkTableName: 23 | Type: String 24 | Description: Name of the DynamoDB table for storing links 25 | Default: link-table-db 26 | StastTableName: 27 | Type: String 28 | Description: Name of the DynamoDB table for storing stats 29 | Default: stats-table-db 30 | 31 | Resources: 32 | LambdaExecutionRole: 33 | Type: AWS::IAM::Role 34 | Properties: 35 | AssumeRolePolicyDocument: 36 | Version: '2012-10-17' 37 | Statement: 38 | - Effect: Allow 39 | Principal: 40 | Service: [lambda.amazonaws.com] 41 | Action: ['sts:AssumeRole'] 42 | Policies: 43 | - PolicyName: LambdaExecutionPolicy 44 | PolicyDocument: 45 | Version: '2012-10-17' 46 | Statement: 47 | - Effect: Allow 48 | Action: 49 | - logs:CreateLogGroup 50 | - logs:CreateLogStream 51 | - logs:PutLogEvents 52 | Resource: 'arn:aws:logs:*:*:*' 53 | - Effect: Allow 54 | Action: 55 | - dynamodb:GetItem 56 | - dynamodb:PutItem 57 | - dynamodb:UpdateItem 58 | - dynamodb:DeleteItem 59 | - dynamodb:Query 60 | - dynamodb:Scan 61 | Resource: 62 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${LinkTableName} 63 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${StastTableName} 64 | 65 | NotificationFunctionRole: 66 | Type: AWS::IAM::Role 67 | Properties: 68 | AssumeRolePolicyDocument: 69 | Version: '2012-10-17' 70 | Statement: 71 | - Effect: Allow 72 | Principal: 73 | Service: [lambda.amazonaws.com] 74 | Action: ['sts:AssumeRole'] 75 | Policies: 76 | - PolicyName: NotificationFunctionPolicy 77 | PolicyDocument: 78 | Version: '2012-10-17' 79 | Statement: 80 | - Effect: Allow 81 | Action: 82 | - logs:CreateLogGroup 83 | - logs:CreateLogStream 84 | - logs:PutLogEvents 85 | Resource: 'arn:aws:logs:*:*:*' 86 | - Effect: Allow 87 | Action: 88 | - sqs:ReceiveMessage 89 | - sqs:DeleteMessage 90 | - sqs:GetQueueAttributes 91 | Resource: !GetAtt NotificationQueue.Arn 92 | 93 | GenerateLinkFunctionRole: 94 | Type: AWS::IAM::Role 95 | Properties: 96 | AssumeRolePolicyDocument: 97 | Version: '2012-10-17' 98 | Statement: 99 | - Effect: Allow 100 | Principal: 101 | Service: [lambda.amazonaws.com] 102 | Action: ['sts:AssumeRole'] 103 | Policies: 104 | - PolicyName: GenerateLinkFunctionPolicy 105 | PolicyDocument: 106 | Version: '2012-10-17' 107 | Statement: 108 | - Effect: Allow 109 | Action: 110 | - logs:CreateLogGroup 111 | - logs:CreateLogStream 112 | - logs:PutLogEvents 113 | Resource: 'arn:aws:logs:*:*:*' 114 | - Effect: Allow 115 | Action: 116 | - dynamodb:GetItem 117 | - dynamodb:PutItem 118 | - dynamodb:UpdateItem 119 | - dynamodb:DeleteItem 120 | - dynamodb:Query 121 | - dynamodb:Scan 122 | Resource: 123 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${LinkTableName} 124 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${StastTableName} 125 | - Effect: Allow 126 | Action: 127 | - sqs:SendMessage 128 | Resource: !GetAtt NotificationQueue.Arn 129 | 130 | LambdaDynamoDBPolicy: 131 | Type: AWS::IAM::Policy 132 | Properties: 133 | PolicyName: LambdaDynamoDBPolicy 134 | Roles: 135 | - !Ref LambdaExecutionRole 136 | PolicyDocument: 137 | Version: '2012-10-17' 138 | Statement: 139 | - Effect: Allow 140 | Action: 141 | - dynamodb:GetItem 142 | - dynamodb:PutItem 143 | - dynamodb:UpdateItem 144 | - dynamodb:DeleteItem 145 | - dynamodb:Query 146 | - dynamodb:Scan 147 | Resource: 148 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${LinkTableName} 149 | - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${StastTableName} 150 | 151 | LambdaSQSPolicy: 152 | Type: AWS::IAM::Policy 153 | Properties: 154 | PolicyName: LambdaSQSPolicy 155 | Roles: 156 | - !Ref LambdaExecutionRole 157 | PolicyDocument: 158 | Version: '2012-10-17' 159 | Statement: 160 | - Effect: Allow 161 | Action: 162 | - sqs:SendMessage 163 | Resource: !GetAtt NotificationQueue.Arn 164 | 165 | NotificationQueue: 166 | Type: AWS::SQS::Queue 167 | Properties: 168 | QueueName: NotificationQueue 169 | 170 | GenerateLinkFunction: 171 | Type: AWS::Serverless::Function 172 | Properties: 173 | CodeUri: internal/adapters/functions/generate/ 174 | Role: !GetAtt GenerateLinkFunctionRole.Arn 175 | Handler: main 176 | Policies: 177 | - !Ref LambdaDynamoDBPolicy 178 | - !Ref LambdaSQSPolicy 179 | Environment: 180 | Variables: 181 | LinkTableName: !Ref LinkTableName 182 | StastTableName: !Ref StastTableName 183 | QueueUrl: !GetAtt NotificationQueue.QueueUrl 184 | Events: 185 | Api: 186 | Type: HttpApi 187 | Properties: 188 | Path: /generate 189 | Method: PUT 190 | 191 | RedirectLinkFunction: 192 | Type: AWS::Serverless::Function 193 | Properties: 194 | CodeUri: internal/adapters/functions/redirect/ 195 | Role: !GetAtt LambdaExecutionRole.Arn 196 | Policies: 197 | - !Ref LambdaDynamoDBPolicy 198 | Events: 199 | Api: 200 | Type: HttpApi 201 | Properties: 202 | Path: /t/{id} 203 | Method: GET 204 | Environment: 205 | Variables: 206 | LinkTableName: !Ref LinkTableName 207 | StastTableName: !Ref StastTableName 208 | 209 | StatsLinkFunction: 210 | Type: AWS::Serverless::Function 211 | Properties: 212 | CodeUri: internal/adapters/functions/stats/ 213 | Role: !GetAtt LambdaExecutionRole.Arn 214 | Policies: 215 | - !Ref LambdaDynamoDBPolicy 216 | Events: 217 | Api: 218 | Type: HttpApi 219 | Properties: 220 | Path: /stats 221 | Method: GET 222 | Environment: 223 | Variables: 224 | LinkTableName: !Ref LinkTableName 225 | StastTableName: !Ref StastTableName 226 | 227 | NotificationLinkFunction: 228 | Type: AWS::Serverless::Function 229 | Properties: 230 | CodeUri: internal/adapters/functions/notification/ 231 | Role: !GetAtt NotificationFunctionRole.Arn 232 | Policies: 233 | - !Ref LambdaDynamoDBPolicy 234 | Events: 235 | Api: 236 | Type: HttpApi 237 | Properties: 238 | Path: /notification 239 | Method: POST 240 | SQSEvent: 241 | Type: SQS 242 | Properties: 243 | Queue: !GetAtt NotificationQueue.Arn 244 | BatchSize: 10 245 | Environment: 246 | Variables: 247 | SlackToken: !Ref SlackToken 248 | SlackChannelID: !Ref SlackChannelID 249 | 250 | DeleteLinkFunction: 251 | Type: AWS::Serverless::Function 252 | Properties: 253 | CodeUri: internal/adapters/functions/delete/ 254 | Role: !GetAtt LambdaExecutionRole.Arn 255 | Policies: 256 | - !Ref LambdaDynamoDBPolicy 257 | Events: 258 | Api: 259 | Type: HttpApi 260 | Properties: 261 | Path: /delete 262 | Method: DELETE 263 | Environment: 264 | Variables: 265 | LinkTableName: !Ref LinkTableName 266 | StastTableName: !Ref StastTableName 267 | 268 | LinkTableDB: 269 | Type: AWS::DynamoDB::Table 270 | Properties: 271 | TableName: !Ref LinkTableName 272 | AttributeDefinitions: 273 | - AttributeName: id 274 | AttributeType: S 275 | BillingMode: PAY_PER_REQUEST 276 | KeySchema: 277 | - AttributeName: id 278 | KeyType: HASH 279 | 280 | StatsTableDB: 281 | Type: AWS::DynamoDB::Table 282 | Properties: 283 | TableName: !Ref StastTableName 284 | AttributeDefinitions: 285 | - AttributeName: id 286 | AttributeType: S 287 | BillingMode: PAY_PER_REQUEST 288 | KeySchema: 289 | - AttributeName: id 290 | KeyType: HASH 291 | 292 | ServerlessHttpApi: 293 | Type: AWS::Serverless::HttpApi 294 | Properties: 295 | CorsConfiguration: 296 | AllowMethods: 297 | - GET 298 | - POST 299 | AllowOrigins: 300 | - !Sub https://${CloudFrontDistributionDomainName}/* 301 | 302 | CloudFrontDistribution: 303 | Type: AWS::CloudFront::Distribution 304 | Properties: 305 | DistributionConfig: 306 | Enabled: true 307 | Comment: !Sub '${AWS::StackName} API Gateway CloudFront Distribution' 308 | DefaultCacheBehavior: 309 | TargetOriginId: 'ApiGatewayOrigin' 310 | ViewerProtocolPolicy: 'redirect-to-https' 311 | AllowedMethods: 312 | - GET 313 | - HEAD 314 | - OPTIONS 315 | - PUT 316 | - POST 317 | - PATCH 318 | - DELETE 319 | CachedMethods: 320 | - GET 321 | - HEAD 322 | - OPTIONS 323 | ForwardedValues: 324 | QueryString: true 325 | Headers: 326 | - Origin 327 | Origins: 328 | - Id: 'ApiGatewayOrigin' 329 | DomainName: !Sub '${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com' 330 | CustomOriginConfig: 331 | HTTPPort: 80 332 | HTTPSPort: 443 333 | OriginProtocolPolicy: 'https-only' 334 | 335 | Outputs: 336 | ApiUrl: 337 | Description: API Gateway endpoint URL 338 | Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/ 339 | 340 | CloudFrontDistributionDomainName: 341 | Description: 'CloudFront Distribution Domain Name' 342 | Value: !GetAtt CloudFrontDistribution.DomainName 343 | --------------------------------------------------------------------------------