Skip to content

Commit

Permalink
Reset repo history
Browse files Browse the repository at this point in the history
  • Loading branch information
bfdes committed Apr 28, 2023
0 parents commit 9868ee3
Show file tree
Hide file tree
Showing 17 changed files with 814 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Test
on: push
jobs:
test:
name: Test
runs-on: ubuntu-latest
container: golang
services:
cache:
image: memcached:latest
ports:
- 11211:11211
database:
image: postgres:latest
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: pass
POSTGRES_DB: url-shortener
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Pull repository
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
cache: true
- name: Run tests
run: go test -coverprofile=coverage.txt
env:
MEMCACHED_HOST: cache
POSTGRES_HOST: database
- name: Upload test coverage report
uses: codecov/codecov-action@v3
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM golang as builder
RUN mkdir /build
COPY . /build/
WORKDIR /build
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o main .
FROM scratch
LABEL maintainer="Bruno Fernandes <bfdes@users.noreply.github.com>"
COPY --from=builder /build/main /app/
WORKDIR /app
CMD ["./main"]
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# url-shortener

![GitHub Actions](https://github.com/bfdes/url-shortener/workflows/Build/badge.svg)
[![Codecov](https://codecov.io/gh/bfdes/url-shortener/branch/master/graph/badge.svg)](https://codecov.io/gh/bfdes/url-shortener)

URL shortener and redirect service [designed for](https://www.notion.so/URL-shortening-8272c692648143698859d9f3524a8b5e#a2becb53582444cfb3e9cce1dd8978ba) low-latency, read-heavy use.

## Usage

### Requirements

- [Go](https://golang.org/) 1.20.*
- [Docker Engine](https://docs.docker.com/engine/) 20.*
- [Docker Compose](https://docs.docker.com/compose/) 2.*

Run the following command within the repository root to start container dependencies in the background:

```shell
docker compose up --detach cache database
```

Then, when the databases are ready to accept connections, start the server with `go run .`.

### Shorten a URL

```shell
curl http://localhost:8080/api/links \
--request POST \
--data '{"url": "http://example.com"}'
# {"url": "http://example.com", "slug": "<SLUG>" }
```

### Redirect a URL

```shell
curl http://localhost:8080/<SLUG>
```

### Testing

Run unit and integration tests with `go test` after starting container dependencies.

[GitHub Actions](https://github.com/bfdes/url-shortener/actions) will run tests for every code push.

## Deployment

This URL shortener is unsuited for production use; it does not support logging or metric collection.
49 changes: 49 additions & 0 deletions base62.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"errors"
"fmt"
"strings"
)

// Characters defines the character set for base 62 encoding
const Characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

const base = len(Characters)

var digits = make(map[rune]int)

func init() {
for i, char := range Characters {
digits[char] = i
}
}

// Encode a non-negative integer into a base 62 symbol string
func Encode(id int) (string, error) {
if id < 0 {
return "", errors.New("argument to Encode must be non-negative")
}
var sb strings.Builder
for id > 0 {
rem := id % base
sb.WriteByte(Characters[rem])
id = id / base
}
return sb.String(), nil
}

// Decode a base 62 encoded string
func Decode(str string) (int, error) {
id := 0
coeff := 1
for _, char := range str {
digit := digits[char]
if char != '0' && digit == 0 {
return 0, fmt.Errorf(`argument "%s" contains illegal character(s)`, str)
}
id += coeff * digit
coeff *= base
}
return id, nil
}
78 changes: 78 additions & 0 deletions base62_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"math/rand"
"testing"
)

type testInput struct {
decoded int
encoded string
}

var testInputs = []testInput{
{0, ""},
{1, "1"},
{62, "01"},
{1504, "go"},
}

func TestEncode(t *testing.T) {
for _, testInput := range testInputs {
actual, err := Encode(testInput.decoded)
if err != nil {
t.Fatal(err)
}
if actual != testInput.encoded {
t.Errorf("%d encoded to %s, not %s", testInput.decoded, actual, testInput.encoded)
}
}
}

func TestEncodeNegative(t *testing.T) {
arg := -1
encoded, err := Encode(arg)
if err == nil {
t.Errorf("negative argument %d encoded to %s", arg, encoded)
}
}

func TestDecode(t *testing.T) {
for _, testInput := range testInputs {
actual, err := Decode(testInput.encoded)
if err != nil {
t.Fatal(err)
}
if actual != testInput.decoded {
t.Errorf("%s decoded to %d, not %d", testInput.encoded, actual, testInput.decoded)
}
}
}

func TestDecodeIllegalCharacter(t *testing.T) {
arg := "!llegal"
decoded, err := Decode(arg)
if err == nil {
t.Errorf("malformed slug %s decoded to %d", arg, decoded)
}
}

func TestEncodeDecode(t *testing.T) {
testInputs := []int{0, 1, 62}
for i := 0; i < 20; i++ {
testInputs = append(testInputs, rand.Int())
}
for _, testInput := range testInputs {
encoded, err := Encode(testInput)
if err != nil {
t.Fatal(err)
}
actual, err := Decode(encoded)
if err != nil {
t.Fatal(err)
}
if actual != testInput {
t.Errorf("%d does not roundtrip", testInput)
}
}
}
18 changes: 18 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: '3.8'
services:
cache:
image: memcached:latest
ports:
- 11211:11211
database:
image: postgres:latest
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=url-shortener
volumes:
- data:/var/lib/postgresql/data/
ports:
- 5432:5432
volumes:
data:
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module github.com/bfdes/url-shortener

go 1.20

require (
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b
github.com/google/uuid v1.3.0
github.com/lib/pq v1.10.9
)
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b h1:L/QXpzIa3pOvUGt1D1lA5KjYhPBAN/3iWdP7xeFS9F0=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
66 changes: 66 additions & 0 deletions handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"encoding/json"
"io/ioutil"
"net/http"
"strings"
)

func RedirectHandler(service LinkService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
msg := http.StatusText(http.StatusMethodNotAllowed)
http.Error(w, msg, http.StatusMethodNotAllowed)
return
}
slug := r.URL.Path[1:]
url, err := service.Get(slug)
if err == ErrDecodeFailure {
msg := http.StatusText(http.StatusBadRequest)
http.Error(w, msg, http.StatusBadRequest)
} else if err == ErrNotFound {
http.NotFound(w, r)
} else {
// `err` must be `nil`...
http.Redirect(w, r, url, http.StatusPermanentRedirect)
}
})
}

func CreateLinkHandler(service LinkService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
msg := http.StatusText(http.StatusMethodNotAllowed)
http.Error(w, msg, http.StatusMethodNotAllowed)
return
}
if r.Body == nil || r.Body == http.NoBody {
msg := http.StatusText(http.StatusBadRequest)
http.Error(w, msg, http.StatusBadRequest)
return
}
payload := Link{}
body, err := ioutil.ReadAll(r.Body) // DOS attack vector
defer r.Body.Close()
if err == nil {
err = json.Unmarshal(body, &payload)
}
if err != nil {
msg := http.StatusText(http.StatusBadRequest)
http.Error(w, msg, http.StatusBadRequest)
return
}
url := strings.TrimSpace(payload.URL)
link, err := service.Create(url)
if err != nil {
msg := http.StatusText(http.StatusInternalServerError)
http.Error(w, msg, http.StatusInternalServerError)
return
}
res, _ := json.Marshal(link)
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write(res)
})
}

0 comments on commit 9868ee3

Please sign in to comment.