30 Commits

Author SHA1 Message Date
Juan Rodriguez a85d5a8c73 chore: bump version 2024-07-14 22:30:15 +02:00
Juan Rodriguez 80cebe3357 fix: update slug size after 2nd attempt 2024-07-14 22:30:15 +02:00
Juan Rodriguez 451a5fbf0f fix: link email validate format regex 2024-07-14 14:33:34 +02:00
Juan Rodriguez aeb6d1164b fix: remove X-Powered-By header 2024-07-14 14:33:19 +02:00
Juan Rodriguez 2f14cd82dd fix: error handling override kemal default response 2024-07-14 11:19:59 +02:00
Juan Rodriguez faedd0bc7a fix: missing errors content type 2024-07-14 09:26:38 +02:00
Juan Rodriguez 1d207fae64 fix: log level error for production env 2024-07-14 09:26:25 +02:00
Juan Rodriguez a71f345f66 refactor: APP_URL + DATABASE_URL default values 2024-07-14 08:46:29 +02:00
Juan Rodriguez 7cc6c1197f refactor: auto run migrations on startup 2024-07-14 08:46:11 +02:00
Juan Rodriguez 115bbf7366 refactor: reduce docker image size 2024-07-12 23:18:06 +02:00
Juan Rodriguez 36a06ac670 chore: update README v1.1.0 2024-07-12 21:53:32 +02:00
Juan Rodriguez 772897cb27 ci: bump version to 1.1.0 2024-07-12 21:44:59 +02:00
Juan Rodriguez 1f42798d12 chore: update READMe with rest API response examples 2024-07-12 21:44:36 +02:00
Juan Rodriguez 08302e4457 ci: fix publish docker image extract_version 2024-07-12 21:29:15 +02:00
Juan Rodriguez cd9a44fcc9 fix: link controller preload + headers validation 2024-07-12 21:28:44 +02:00
Juan Rodriguez 6532ff466e test: add missing cases for link controller integration tests 2024-07-12 21:28:25 +02:00
Juan Rodriguez 8cbed80fd0 chore: update benchmark script 2024-07-12 20:52:57 +02:00
Juan Rodriguez 0758bf4cee fix: existing_links preload clicks 2024-07-12 20:26:43 +02:00
Juan Rodriguez f5c296bee3 Merge branch 'master' of https://github.com/sjdonado/bit 2024-07-12 19:00:55 +02:00
Juan Rodriguez 8f33375b5f fix: link clicks empty array validation 2024-07-12 19:00:44 +02:00
Juan Rodriguez cebbd48237 ci: update dockerfile to include data folder 2024-07-12 10:03:37 +02:00
Juan Rodriguez 88d81ecfe3 ci: update dockerfile to include data folder 2024-07-12 08:50:14 +02:00
Juan Rodriguez 3050c2b100 feat: get link by id endpoint 2024-07-12 08:47:08 +02:00
Juan Rodriguez a2aa586dae feat: get all links clicks join 2024-07-12 07:58:21 +02:00
Juan Rodriguez ebc9c6852e ci: download regexes script 2024-07-12 07:56:19 +02:00
Juan Rodriguez a1b67b8553 fix: avoid create links duplicates 2024-07-12 07:46:58 +02:00
Juan Rodriguez dbc81796d6 feat: store click data on redirect 2024-07-12 07:46:49 +02:00
Juan Rodriguez 2f796dbdab feat: create clicks model + migration 2024-07-12 07:28:27 +02:00
Juan Rodriguez ff06e10b8f ci: github workflow extract release tag from shard.yml 2024-07-11 22:45:46 +02:00
Juan Rodriguez 050fd6f1e3 refactor: rename url-shortener to bit 2024-07-10 22:29:34 +02:00
27 changed files with 6555 additions and 135 deletions
-1
View File
@@ -1,6 +1,5 @@
.git .git
/bin/ /bin/
/.shards/ /.shards/
/bruno/
/spec/ /spec/
/sqlite/ /sqlite/
+23 -8
View File
@@ -19,19 +19,35 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version tag
id: extract_tag - name: Extract version from shard.yml
if: github.event_name == 'release' id: extract_version
run: echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: |
VERSION=$(grep -oP 'version:\s*\K\S+' shard.yml)
VERSION=$(echo $VERSION | tr -d '\n\r')
echo "RELEASE_TAG=$VERSION" >> $GITHUB_ENV
- name: Set tags
id: set_tags
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "TAGS=latest,${{ env.RELEASE_TAG }}" >> $GITHUB_ENV
else
echo "TAGS=latest" >> $GITHUB_ENV
fi
- name: Build and push image - name: Build and push image
id: push id: push
uses: docker/build-push-action@v5.0.0 uses: docker/build-push-action@v5.0.0
@@ -39,12 +55,11 @@ jobs:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: sjdonado/bit:${{ env.TAGS }}
sjdonado/url-shortener:latest
${{ github.event_name == 'release' && 'sjdonado/url-shortener:${{ env.RELEASE_TAG }}' || '' }}
- name: Attest - name: Attest
uses: actions/attest-build-provenance@v1 uses: actions/attest-build-provenance@v1
id: attest id: attest
with: with:
subject-name: sjdonado/url-shortener subject-name: sjdonado/bit
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
+2
View File
@@ -7,3 +7,5 @@
/sqlite/ /sqlite/
.env.production .env.production
resource_usage.txt
+28 -9
View File
@@ -1,19 +1,38 @@
FROM alpine:edge as base FROM alpine:edge AS build
ENV ENV=production
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add crystal shards sqlite-dev openssl-dev RUN apk update && apk add --no-cache \
crystal \
shards \
yaml-dev \
sqlite-dev \
openssl-dev
FROM base AS build COPY . .
ENV ENV=production
COPY . .
RUN shards install RUN shards install
RUN shards build --progress RUN shards build --release --no-debug
FROM alpine:edge AS runtime
ENV ENV=production
WORKDIR /usr/src/app
RUN apk add --no-cache \
gc \
pcre2 \
libevent \
yaml \
sqlite-libs \
openssl
RUN mkdir -p sqlite
FROM base AS release
RUN mkdir -p /usr/src/app/sqlite
COPY --from=build /usr/src/app/db db COPY --from=build /usr/src/app/db db
COPY --from=build /usr/src/app/data data
COPY --from=build /usr/src/app/bin /usr/local/bin COPY --from=build /usr/src/app/bin /usr/local/bin
EXPOSE 4000/tcp EXPOSE 4000/tcp
CMD ["url-shortener"] CMD ["bit"]
+199 -54
View File
@@ -1,15 +1,23 @@
# url-shortener [![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit)
> Lightning fast, lightweight and minimal self-hosted url shortener [![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit)
[![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/repository/docker/sjdonado/bit)
# Benchmark
## Benchmark
```shell ```shell
./benchmark.sh $ ./benchmark.sh
Semaphore initialized with 2666 slots. Semaphore initialized with 2666 slots.
Setup... Setup...
[+] Running 1/0 [+] Running 2/2
Container url-shortener-app-1 Running 0.0s Network bit_default Created 0.0s
2024-05-20T16:39:53.818306Z INFO - micrate: No migrations to run. current version: 20240513130054 ✔ Container bit-app-1 Started 0.2s
Captured API Key: 4y2mblZDneZLcI-YywHGFA 2024-07-12T18:41:20.962052Z INFO - micrate: Migrating db, current version: 0, target: 20240711224103
2024-07-12T18:41:20.965729Z INFO - micrate: OK 20240512214223_create_links.sql
2024-07-12T18:41:20.969198Z INFO - micrate: OK 20240512225208_add_slug_index_to_links.sql
2024-07-12T18:41:20.973136Z INFO - micrate: OK 20240513115731_create_users.sql
2024-07-12T18:41:20.975525Z INFO - micrate: OK 20240513130054_add_api_key_index_to_users.sql
2024-07-12T18:41:20.979195Z INFO - micrate: OK 20240711224103_create_clicks.sql
Captured API Key: Z01Qk4M5E0xhggZUCdQAPw
Waiting for database to be ready... Waiting for database to be ready...
Creating 1000 short links... Creating 1000 short links...
Created short link 100/1000 Created short link 100/1000
@@ -24,74 +32,215 @@ Created short link 900/1000
Created short link 1000/1000 Created short link 1000/1000
Accessing each link 10 times concurrently... Accessing each link 10 times concurrently...
****Results**** ****Results****
Average Memory Usage: 11.00 MiB Average Memory Usage: 16.36 MiB
Average Response Time: 5.28 µs Average CPU Usage: 0%
[+] Running 2/2 Average Response Time: 12.37 µs
✔ Container url-shortener-app-1 Removed 10.2s
✔ Network url-shortener_default Removed
``` ```
## Self-hosted # Self-hosted
## Run via docker-compose
- Run via docker-compose
```bash ```bash
docker-compose up docker-compose up
docker-compose exec -it app migrate # Generate an api key
docker-compose exec -it app cli --create-user=Admin docker-compose exec -it app cli --create-user=Admin
``` ```
- Run via docker cli ## Run via docker cli
```bash ```bash
docker run \ docker run \
--name url-shortener \ --name bit \
-p 4000:4000 \ -p 4000:4000 \
-e ENV="production" \ -e ENV="production" \
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \ -e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
-e APP_URL="http://localhost:4000" \ -e APP_URL="http://localhost:4000" \
sjdonado/url-shortener sjdonado/bit
docker exec -it url-shortener migrate docker exec -it bit cli --create-user=Admin
docker exec -it url-shortener cli --create-user=Admin
``` ```
- Dokku ## Dokku
```dockerfile ```dockerfile
FROM sjdonado/url-shortener FROM sjdonado/bit
``` ```
```bash ```bash
dokku apps:create url-shortener dokku apps:create bit
dokku domains:set url-shortener bit.donado.co dokku domains:set bit bit.donado.co
dokku letsencrypt:enable url-shortener dokku letsencrypt:enable bit
dokku storage:ensure-directory url-shortener-sqlite dokku storage:ensure-directory bit-sqlite
dokku storage:mount url-shortener /var/lib/dokku/data/storage/url-shortener-sqlite:/usr/src/app/sqlite/ dokku storage:mount bit /var/lib/dokku/data/storage/bit-sqlite:/usr/src/app/sqlite/
dokku config:set url-shortener DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" APP_URL=https://bit.donado.co dokku config:set bit DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" APP_URL=https://bit.donado.co
dokku ports:add url-shortener http:80:4000 dokku ports:add bit http:80:4000
dokku ports:add url-shortener https:443:4000 dokku ports:add bit https:443:4000
dokku run url-shortener migrate dokku run bit cli --create-user=Admin
dokku run url-shortener cli --create-user=Admin
``` ```
## Usage # Usage
**REST API** ## API Endpoints
| Endpoint | HTTP Method | Description | Payload | 1. **Ping the API**
|----------|-------------|-------------|---------|
| `/api/ping` | GET | Ping the API to check if it's running | - | - Endpoint: `GET /api/ping`
| `/:slug` | GET | Retrieve a link by its slug | - | - Payload: None
| `/api/links` | GET | Retrieve all links | - | - Response Example
| `/api/links` | POST | Create a new link | `{"url": "https://example.com"}` | ```json
| `/api/links/:id` | PUT | Update an existing link by its ID | `{"url": "https://newexample.com"}` | {
| `/api/links/:id` | DELETE | Delete a link by its ID | - | "message": "pong"
}
```
2. **Retrieve a link by its slug**
- Endpoint: `GET /:slug`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": {
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
"refer": "http://localhost:4000/3wP4BQ",
"origin": "https://monocuco.donado.co",
"clicks": [
{
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
"language": "en-US",
"browser": "Firefox",
"os": "Mac OS X",
"source": "Unknown",
"created_at": "2024-07-12T19:25:22Z"
}
]
}
}
```
3. **Retrieve all links**
- Endpoint: `GET /api/links`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": [
{
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
"refer": "http://localhost:4000/3wP4BQ",
"origin": "https://monocuco.donado.co",
"clicks": [
{
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
"language": "en-US",
"browser": "Firefox",
"os": "Mac OS X",
"source": "Unknown",
"created_at": "2024-07-12T19:25:22Z"
}
]
}
]
}
```
4. **Retrieve a link by its ID**
- Endpoint: `GET /api/links/:id`
- Headers: `X-Api-Key`
- Payload: None
- Response Example
```json
{
"data": {
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
"refer": "http://localhost:4000/3wP4BQ",
"origin": "https://monocuco.donado.co",
"clicks": [
{
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
"language": "en-US",
"browser": "Firefox",
"os": "Mac OS X",
"source": "Unknown",
"created_at": "2024-07-12T19:25:22Z"
}
]
}
}
```
5. **Create a new link**
- Endpoint\*\*: `POST /api/links`
- Payload:
```json
{
"url": "https://example.com"
}
```
- Headers: `X-Api-Key`
- Response Example:
```json
{
"data": {
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
"refer": "http://localhost:4000/3wP4BQ",
"origin": "https://monocuco.donado.co/test",
"clicks": []
}
}
```
6. **Update an existing link by its ID**
- Endpoint: `PUT /api/links/:id`
- Payload:
```json
{
"url": "https://newexample.com"
}
```
- Headers: `X-Api-Key`
- Response Example:
```json
{
"data": {
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
"refer": "http://localhost:4000/3wP4BQ",
"origin": "https://newexample.com",
"clicks": []
}
}
```
7. **Delete a link by its ID**
- Endpoint: `DELETE /api/links/:id`
- Payload: None
- Headers: `X-Api-Key`
- Response Example:
```json
{
"message": "Link deleted"
}
```
## CLI
**CLI**
``` ```
Usage: ./cli [options] Usage: ./cli [options]
Options: Options:
@@ -100,9 +249,9 @@ Options:
--delete-user=USER_ID Delete a user by ID --delete-user=USER_ID Delete a user by ID
``` ```
## Development # Development
**Installation** ## Installation
```bash ```bash
brew tap amberframework/micrate brew tap amberframework/micrate
@@ -110,29 +259,25 @@ brew install micrate
``` ```
```bash ```bash
shards run migrate shards run bit
shards run url-shortener
``` ```
**Generate the `X-Api-Key`** ## Generate the `X-Api-Key`
```bash ```bash
shards run cli -- --create-user=Admin shards run cli -- --create-user=Admin
``` ```
## Run tests ## Run tests
```bash ```bash
ENV=test crystal spec ENV=test crystal spec
``` ```
## Contributing ## Contributing
1. Fork it (<https://github.com/sjdonado/url-shortener/fork>) 1. Fork it (<https://github.com/sjdonado/bit/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`) 2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`) 3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`) 4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request 5. Create a new Pull Request
## Contributors
- [sjdonado](https://github.com/sjdonado) - creator and maintainer
+9
View File
@@ -1,6 +1,15 @@
require "log"
ENV["ENV"] ||= "development" ENV["ENV"] ||= "development"
ENV["APP_URL"] ||= "http://localhost:4000"
ENV["DATABASE_URL"] ||= "sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
"
{% if env("ENV") != "production" %} {% if env("ENV") != "production" %}
require "dotenv" require "dotenv"
Dotenv.load ".env.#{ENV["ENV"]}" # File must exist in non-production! Dotenv.load ".env.#{ENV["ENV"]}" # File must exist in non-production!
{% end %} {% end %}
{% if env("ENV") == "production" %}
Log.setup(:error)
{% end %}
+65 -12
View File
@@ -1,4 +1,7 @@
require "uuid" require "uuid"
require "user_agent_parser"
UserAgent.load_regexes(File.read("data/regexes.yaml"))
require "../lib/controller.cr" require "../lib/controller.cr"
@@ -10,19 +13,38 @@ module App::Controllers::Link
def call(env) def call(env)
user = env.get("user").as(User) user = env.get("user").as(User)
body = parse_body(env, ["url"]) body = parse_body(env, ["url"])
url = body["url"].to_s
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
existing_link = Database.all(Link, query, preload: [:clicks]).first?
if existing_link
response = {"data" => App::Serializers::Link.new(existing_link)}
return response.to_json
end
link = Link.new link = Link.new
link.id = UUID.v4.to_s link.id = UUID.v4.to_s
link.url = body["url"].to_s link.url = url
link.slug = Random::Secure.urlsafe_base64(4)
link.user = user link.user = user
attempts = 0
loop do
slug = Random::Secure.urlsafe_base64(attempts >= 2 ? 6 : 5).gsub(/[^a-zA-Z0-9]/, "")
unless Database.get_by(Link, slug: slug)
link.slug = slug
break
end
attempts += 1
end
changeset = Database.insert(link) changeset = Database.insert(link)
if !changeset.valid? if !changeset.valid?
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors)) raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
end end
link.clicks = [] of App::Models::Click
response = {"data" => App::Serializers::Link.new(link)} response = {"data" => App::Serializers::Link.new(link)}
response.to_json response.to_json
end end
end end
@@ -38,11 +60,26 @@ module App::Controllers::Link
raise App::NotFoundException.new(env) if !link raise App::NotFoundException.new(env) if !link
spawn do spawn do
link.click_counter = link.click_counter! + 1 user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
user_agent = user_agent_str != "Unknown" ? UserAgent.new(user_agent_str) : nil
changeset = Database.update(link) language_header = env.request.headers["Accept-Language"]? || "Unknown"
language = language_header.split(',').first.split(';').first
referer = env.request.headers["Referer"]?
click = Click.new
click.id = UUID.v4.to_s
click.link = link
click.language = language
click.user_agent = user_agent_str
click.browser = user_agent ? user_agent.family : "Unknown"
click.os = user_agent ? (user_agent.os.try &.family || "Unknown") : "Unknown"
click.source = referer ? URI.parse(referer).host : "Unknown"
changeset = Database.insert(click)
if changeset.errors.any? if changeset.errors.any?
Log.error { "Increase click counter failed: #{changeset.errors}" } Log.error { "Logging click event failed: #{changeset.errors}" }
end end
end end
@@ -61,13 +98,31 @@ module App::Controllers::Link
user = env.get("user").as(User) user = env.get("user").as(User)
query = Database::Query.where(user_id: user.id.as(String)) query = Database::Query.where(user_id: user.id.as(String))
links = Database.all(Link, query) links = Database.all(Link, query, preload: [:clicks])
response = {"data" => links.map { |link| App::Serializers::Link.new(link) }} response = {"data" => links.map { |link| App::Serializers::Link.new(link) }}
response.to_json response.to_json
end end
end end
class Get < App::Lib::BaseController
include App::Models
include App::Lib
def call(env)
user = env.get("user").as(User)
link_id = env.params.url["id"]
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
link = Database.all(Link, query, preload: [:clicks]).first?
raise App::NotFoundException.new(env) if link.nil?
response = {"data" => App::Serializers::Link.new(link)}
response.to_json
end
end
class Update < App::Lib::BaseController class Update < App::Lib::BaseController
include App::Models include App::Models
include App::Lib include App::Lib
@@ -77,15 +132,13 @@ module App::Controllers::Link
id = env.params.url["id"] id = env.params.url["id"]
body = parse_body(env, ["url"]) body = parse_body(env, ["url"])
link = Database.get(Link, id) query = Database::Query.where(id: id).limit(1)
raise App::NotFoundException.new(env) if !link link = Database.all(Link, query, preload: [:clicks]).first?
if link.user_id != user.id raise App::NotFoundException.new(env) if link.nil?
raise App::ForbiddenException.new(env) raise App::ForbiddenException.new(env) if link.user_id != user.id
end
link.url = body["url"].to_s link.url = body["url"].to_s
link.click_counter = 0
changeset = Database.update(link) changeset = Database.update(link)
if !changeset.valid? if !changeset.valid?
+8
View File
@@ -1,5 +1,6 @@
require "sqlite3" require "sqlite3"
require "crecto" require "crecto"
require"micrate"
module App::Lib module App::Lib
class Database class Database
@@ -14,5 +15,12 @@ module App::Lib
if ENV["ENV"] == "development" if ENV["ENV"] == "development"
Crecto::DbLogger.set_handler(STDOUT) Crecto::DbLogger.set_handler(STDOUT)
end end
def self.run_migrations
Micrate::DB.connection_url = ENV["DATABASE_URL"]
Micrate::Cli.run_up
end
run_migrations
end end
end end
+25
View File
@@ -1,8 +1,18 @@
require "kemal" require "kemal"
module App module App
class InternalServerErrorException < Kemal::Exceptions::CustomException
def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 500
context.response.print({ "error" => "Internal Server Error" }.to_json)
super(context)
end
end
class BadRequestException < Kemal::Exceptions::CustomException class BadRequestException < Kemal::Exceptions::CustomException
def initialize(context, message : String) def initialize(context, message : String)
context.response.content_type = "application/json"
context.response.status_code = 400 context.response.status_code = 400
context.response.print({ "error" => message }.to_json) context.response.print({ "error" => message }.to_json)
super(context) super(context)
@@ -11,13 +21,16 @@ module App
class UnauthorizedException < Kemal::Exceptions::CustomException class UnauthorizedException < Kemal::Exceptions::CustomException
def initialize(context) def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 401 context.response.status_code = 401
context.response.print({ "error" => "Unauthorized access" }.to_json)
super(context) super(context)
end end
end end
class ForbiddenException < Kemal::Exceptions::CustomException class ForbiddenException < Kemal::Exceptions::CustomException
def initialize(context) def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 403 context.response.status_code = 403
context.response.print({ "error" => "Access not allowed" }.to_json) context.response.print({ "error" => "Access not allowed" }.to_json)
super(context) super(context)
@@ -26,16 +39,28 @@ module App
class NotFoundException < Kemal::Exceptions::CustomException class NotFoundException < Kemal::Exceptions::CustomException
def initialize(context) def initialize(context)
context.response.content_type = "application/json"
context.response.status_code = 404 context.response.status_code = 404
context.response.print({ "error" => "Resource not found" }.to_json)
super(context) super(context)
end end
end end
class UnprocessableEntityException < Kemal::Exceptions::CustomException class UnprocessableEntityException < Kemal::Exceptions::CustomException
def initialize(context, message : Hash(String, Array(String))) def initialize(context, message : Hash(String, Array(String)))
context.response.content_type = "application/json"
context.response.status_code = 422 context.response.status_code = 422
context.response.print({ "errors" => message }.to_json) context.response.print({ "errors" => message }.to_json)
super(context) super(context)
end end
end end
end end
error 500 do |env|
App::InternalServerErrorException.new(env)
""
end
error 404 do |env|
""
end
+18
View File
@@ -0,0 +1,18 @@
require "crecto"
module App::Models
class Click < Crecto::Model
schema :clicks do
field :id, String, primary_key: true
field :user_agent, String
field :language, String
field :browser, String
field :os, String
field :source, String
belongs_to :link, Link
end
validate_required [:user_agent, :language, :source]
end
end
+2 -2
View File
@@ -9,14 +9,14 @@ module App::Models
field :id, String, primary_key: true field :id, String, primary_key: true
field :slug, String field :slug, String
field :url, String field :url, String
field :click_counter, Int64, default: 0
belongs_to :user, User belongs_to :user, User
has_many :clicks, Click
end end
unique_constraint :slug unique_constraint :slug
validate_required [:slug, :url] validate_required [:slug, :url]
validate_format :url, /\A(?:https?:\/\/)?(?:[\w-]+\.)+[\w-]+(?:\/\S*)?/ validate_format :url, /\Ahttps?:\/\/(?:[\w.-]+)(?::\d+)?(?:[\/?#]\S*)?\z/i
end end
end end
+6 -1
View File
@@ -4,7 +4,8 @@ module App
before_all do |env| before_all do |env|
env.response.headers["Access-Control-Allow-Origin"] = "*" env.response.headers["Access-Control-Allow-Origin"] = "*"
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, X-Api-Key" env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
env.response.headers.delete("X-Powered-By")
end end
after_all do |env| after_all do |env|
@@ -23,6 +24,10 @@ module App
Controllers::Link::All.new.call(env) Controllers::Link::All.new.call(env)
end end
get "/api/links/:id" do |env|
Controllers::Link::Get.new.call(env)
end
post "/api/links" do |env| post "/api/links" do |env|
Controllers::Link::Create.new.call(env) Controllers::Link::Create.new.call(env)
end end
+22
View File
@@ -0,0 +1,22 @@
require "json"
require "../models/click"
module App::Serializers
class Click
def initialize(@click : App::Models::Click)
end
def to_json(builder : JSON::Builder)
builder.object do
builder.field("id", @click.id)
builder.field("user_agent", @click.user_agent)
builder.field("language", @click.language)
builder.field("browser", @click.browser)
builder.field("os", @click.os)
builder.field("source", @click.source)
builder.field("created_at", @click.created_at)
end
end
end
end
+2 -1
View File
@@ -1,6 +1,7 @@
require "json" require "json"
require "../models/link" require "../models/link"
require "./click"
module App::Serializers module App::Serializers
class Link class Link
@@ -15,7 +16,7 @@ module App::Serializers
builder.field("id", @link.id) builder.field("id", @link.id)
builder.field("refer", @refer) builder.field("refer", @refer)
builder.field("origin", @link.url) builder.field("origin", @link.url)
builder.field("clicks", @link.click_counter) builder.field("clicks", @link.clicks.map { |click| App::Serializers::Click.new(click) })
end end
end end
end end
+18 -15
View File
@@ -21,34 +21,33 @@ echo "Semaphore initialized with $max_concurrent_processes slots."
function get_resource_usage { function get_resource_usage {
while true; do while true; do
docker stats --no-stream --format "{{.MemUsage}} {{.CPUPerc}}" url-shortener >> resource_usage.txt docker stats --no-stream --format "table {{.MemUsage}} {{.CPUPerc}}" bit-app-1 | awk 'NR>1 {print "Memory:", $1, "CPU:", $2}' >> resource_usage.txt
sleep $resource_usage_interval sleep $resource_usage_interval
done done
} }
function calculate_average_usage { function calculate_average_usage {
total_mem=0 total_mem=0
total_cpu=0
count=0 count=0
while read -r line; do while read -r line; do
mem=$(echo $line | awk '{print $1}') if echo $line | grep -q 'Memory'; then
mem=$(echo $line | awk '{print $2}' | sed 's/MiB//')
# Convert memory to MiB if necessary total_mem=$(echo "$total_mem + $mem" | bc)
if [[ $mem == *MiB ]]; then elif echo $line | grep -q 'CPU'; then
mem=$(echo $mem | sed 's/MiB//') cpu=$(echo $line | awk '{print $2}' | sed 's/%//')
elif [[ $mem == *GiB ]]; then total_cpu=$(echo "$total_cpu + $cpu" | bc)
mem=$(echo $mem | sed 's/GiB//')
mem=$(echo "$mem * 1024" | bc)
fi fi
total_mem=$(echo "$total_mem + $mem" | bc)
((count++)) ((count++))
done < resource_usage.txt done < resource_usage.txt
avg_mem=$(echo "scale=2; $total_mem / $count" | bc) avg_mem=$(echo "scale=2; $total_mem / ($count / 2)" | bc) # Since there are 2 lines per interval
avg_cpu=$(echo "scale=2; $total_cpu / ($count / 2)" | bc)
rm resource_usage.txt rm resource_usage.txt
echo "Average Memory Usage: $avg_mem MiB" echo "Average Memory Usage: $avg_mem MiB"
echo "Average CPU Usage: $avg_cpu%"
} }
function measure { function measure {
@@ -67,8 +66,8 @@ function measure {
--header "X-Api-Key: $api_key" \ --header "X-Api-Key: $api_key" \
--header "Content-Type: application/json" \ --header "Content-Type: application/json" \
--data "{ \"url\": \"https://kagi.com\" }") --data "{ \"url\": \"https://kagi.com\" }")
refer=$(echo $response | awk -F'"' '/"refer":/{print $(NF-1)}') refer=$(echo $response | awk -F'"' '/"refer":/{print $(NF-1)}')
if [[ -n $refer ]]; then if [[ -n $refer ]]; then
refer_links+=("$refer") refer_links+=("$refer")
if (( i % 100 == 0 )); then if (( i % 100 == 0 )); then
@@ -76,6 +75,8 @@ function measure {
fi fi
else else
echo "Failed to create short link $i" echo "Failed to create short link $i"
echo $response
exit 1
fi fi
done done
@@ -128,8 +129,10 @@ function measure {
echo "Setup..." echo "Setup..."
docker-compose up -d docker-compose up -d
# Ensure migrations are done if [ $? -ne 0 ]; then
docker-compose exec -T app migrate echo "Failed to start Docker containers."
exit 1
fi
# Create a new user and capture the API key # Create a new user and capture the API key
output=$(docker-compose exec -T app cli --create-user=Admin) output=$(docker-compose exec -T app cli --create-user=Admin)
-4
View File
@@ -11,8 +11,4 @@ require "./app/routes"
add_context_storage_type(App::Models::User) add_context_storage_type(App::Models::User)
add_handler(App::Middlewares::Auth.new) add_handler(App::Middlewares::Auth.new)
error 500 { |env| {"error" => "Internal Server Error" }.to_json}
error 401 { |env| {"error" => "Unauthorized" }.to_json}
error 404 { |env| {"error" => "Not Found" }.to_json}
Kemal.run Kemal.run
+5957
View File
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ CREATE TABLE links (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
slug VARCHAR(4) UNIQUE NOT NULL, slug VARCHAR(4) UNIQUE NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
click_counter INTEGER NOT NULL DEFAULT 0,
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL, created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
@@ -0,0 +1,19 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE clicks (
id TEXT PRIMARY KEY NOT NULL,
link_id TEXT NOT NULL,
user_agent TEXT,
language TEXT,
browser TEXT,
os TEXT,
source TEXT,
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE
);
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE clicks;
@@ -0,0 +1,49 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
-- Step 1: Create a new table with the desired column type
CREATE TABLE links_new (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
slug VARCHAR(8) UNIQUE NOT NULL,
url TEXT NOT NULL,
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Step 2: Copy data from the old table to the new table
INSERT INTO links_new (id, user_id, slug, url, created_at, updated_at)
SELECT id, user_id, slug, url, created_at, updated_at FROM links;
-- Step 3: Drop the old table
DROP TABLE links;
-- Step 4: Rename the new table to the old table's name
ALTER TABLE links_new RENAME TO links;
-- +micrate Down
-- SQL section 'Down' is executed when this migration is rolled back
-- Step 1: Create a new table with the original column type
CREATE TABLE links_old (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
slug VARCHAR(4) UNIQUE NOT NULL,
url TEXT NOT NULL,
created_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Step 2: Copy data from the current table to the old table
INSERT INTO links_old (id, user_id, slug, url, created_at, updated_at)
SELECT id, user_id, substr(slug, 1, 4), url, created_at, updated_at FROM links;
-- Step 3: Drop the current table
DROP TABLE links;
-- Step 4: Rename the old table to the current table's name
ALTER TABLE links_old RENAME TO links;
+6 -3
View File
@@ -3,7 +3,10 @@ services:
build: . build: .
environment: environment:
ENV: production ENV: production
DATABASE_URL: sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true
APP_URL: http://0.0.0.0:4001
ports: ports:
- 4001:4000 - 4000:4000
volumes:
- sqlite_data:/app/sqlite
volumes:
sqlite_data:
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
REGEXES_URL="https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml"
DOWNLOAD_DIR="data"
REGEXES_FILE="regexes.yaml"
mkdir -p $DOWNLOAD_DIR
curl -L -o $DOWNLOAD_DIR/$REGEXES_FILE $REGEXES_URL
echo "Regexes file downloaded to $DOWNLOAD_DIR/$REGEXES_FILE"
-7
View File
@@ -1,7 +0,0 @@
require "sqlite3"
require"micrate"
require "../app/config/env"
Micrate::DB.connection_url = ENV["DATABASE_URL"]
Micrate::Cli.run_up
+4
View File
@@ -48,3 +48,7 @@ shards:
git: https://github.com/crystal-lang/crystal-sqlite3.git git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.19.0 version: 0.19.0
user_agent_parser:
git: https://github.com/busyloop/user_agent_parser.git
version: 2.0.1
+8 -7
View File
@@ -1,16 +1,14 @@
name: url-shortener name: bit
version: 0.1.0 version: 1.2.0
authors: authors:
- Juan Rodriguez <sjdonado@icloud.com> - Juan Rodriguez <sjdonado@icloud.com>
targets: targets:
url-shortener: bit:
main: url-shortener.cr main: bit.cr
cli: cli:
main: scripts/cli.cr main: scripts/cli.cr
migrate:
main: scripts/migrate.cr
dependencies: dependencies:
kemal: kemal:
@@ -22,6 +20,9 @@ dependencies:
micrate: micrate:
github: amberframework/micrate github: amberframework/micrate
version: 0.15.1 version: 0.15.1
user_agent_parser:
github: busyloop/user_agent_parser
version: 2.0.1
development_dependencies: development_dependencies:
dotenv: dotenv:
@@ -29,6 +30,6 @@ development_dependencies:
spec-kemal: spec-kemal:
github: kemalcr/spec-kemal github: kemalcr/spec-kemal
crystal: '>= 1.12.1' crystal: ">= 1.12.1"
license: MIT license: MIT
+65 -8
View File
@@ -15,10 +15,34 @@ describe "App::Controllers::Link" do
body: payload.to_json body: payload.to_json
) )
parsed_response = Hash(String, Hash(String, String | Int64)).from_json(response.body) parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
parsed_response["data"]["origin"].should eq(payload["url"]) parsed_response["data"]["origin"].should eq(payload["url"])
end end
it "should return existing link if url already exists" do
test_user = create_test_user()
payload = {"url" => "https://kagi.com"}
post(
"/api/links",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
body: payload.to_json
)
first_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
first_response["data"]["origin"].should eq(payload["url"])
post(
"/api/links",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
body: payload.to_json
)
second_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
second_response["data"]["origin"].should eq(payload["url"])
second_response["data"]["id"].should eq(first_response["data"]["id"])
end
it "should return 400 - url required field" do it "should return 400 - url required field" do
test_user = create_test_user() test_user = create_test_user()
@@ -70,20 +94,21 @@ describe "App::Controllers::Link" do
response.headers["Location"].should eq(link) response.headers["Location"].should eq(link)
end end
it "should increase click counter after redirect" do it "should create a new click after redirect" do
link = "https://kagi.com" link = "https://kagi.com"
test_user = create_test_user() test_user = create_test_user()
test_link = create_test_link(test_user, link) test_link = create_test_link(test_user, link)
serialized_link = App::Serializers::Link.new(test_link) serialized_link = App::Serializers::Link.new(test_link)
get(serialized_link.refer, headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) get(serialized_link.refer, headers: HTTP::Headers{"User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0"})
Fiber.yield
Fiber.yield # replace yield with sleep 5 to debug errors
response.headers["Location"].should eq(link) response.headers["Location"].should eq(link)
updated_test_link = get_test_link(test_link.id) updated_test_link = get_test_link(test_link.id)
updated_test_link.click_counter.should eq(1) updated_test_link.clicks.size.should eq(test_link.clicks.size + 1)
end end
it "should return 404 - link does not exist" do it "should return 404 - link does not exist" do
@@ -114,7 +139,7 @@ describe "App::Controllers::Link" do
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
parsed_response = Hash(String, Array(Hash(String, String | Int64))).from_json(response.body) parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
parsed_response["data"][0]["origin"].should eq(links[0]) parsed_response["data"][0]["origin"].should eq(links[0])
parsed_response["data"][1]["origin"].should eq(links[1]) parsed_response["data"][1]["origin"].should eq(links[1])
parsed_response["data"][2]["origin"].should eq(links[2]) parsed_response["data"][2]["origin"].should eq(links[2])
@@ -133,7 +158,7 @@ describe "App::Controllers::Link" do
get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) get("/api/links", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
parsed_response = Hash(String, Array(Hash(String, String | Int64))).from_json(response.body) parsed_response = Hash(String, Array(Hash(String, String | Int64 | Array(Hash(String, String))))).from_json(response.body)
parsed_response["data"].size.should eq(3) parsed_response["data"].size.should eq(3)
parsed_response["data"][0]["origin"].should eq(links[0]) parsed_response["data"][0]["origin"].should eq(links[0])
parsed_response["data"][1]["origin"].should eq(links[1]) parsed_response["data"][1]["origin"].should eq(links[1])
@@ -149,6 +174,38 @@ describe "App::Controllers::Link" do
end end
end end
describe "Get" do
it "should return the specified link with click details" do
link = "https://kagi.com"
test_user = create_test_user()
test_link = create_test_link(test_user, link)
get("/api/links/#{test_link.id}", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
parsed_response["data"]["origin"].should eq(link)
parsed_response["data"]["clicks"].should be_a(Array(Hash(String, String)))
end
it "should return 404 - link does not exist" do
test_user = create_test_user()
get("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
expected = {"error" => "Not Found"}.to_json
response.status_code.should eq(404)
response.body.should eq(expected)
end
it "should return 401 - missing api key" do
get "/api/links/1"
expected = {"error" => "Unauthorized"}.to_json
response.status_code.should eq(401)
response.body.should eq(expected)
end
end
describe "Update" do describe "Update" do
it "should update link url" do it "should update link url" do
link = "https://kagi.com" link = "https://kagi.com"
@@ -162,7 +219,7 @@ describe "App::Controllers::Link" do
body: payload.to_json body: payload.to_json
) )
parsed_response = Hash(String, Hash(String, String | Int64)).from_json(response.body) parsed_response = Hash(String, Hash(String, String | Int64 | Array(Hash(String, String)))).from_json(response.body)
parsed_response["data"]["origin"].should eq(payload["url"]) parsed_response["data"]["origin"].should eq(payload["url"])
end end
+9 -2
View File
@@ -3,7 +3,7 @@ require "uuid"
require "spec-kemal" require "spec-kemal"
require "micrate" require "micrate"
require "../url-shortener" require "../bit"
Spec.before_suite do Spec.before_suite do
Micrate::DB.connection_url = ENV["DATABASE_URL"] Micrate::DB.connection_url = ENV["DATABASE_URL"]
@@ -38,11 +38,18 @@ def create_test_link(user, url)
raise "Test link creation failed" raise "Test link creation failed"
end end
link.clicks = [] of App::Models::Click
link link
end end
def get_test_link(link_id) def get_test_link(link_id)
App::Lib::Database.get!(App::Models::Link, link_id) query = App::Lib::Database::Query.where(id: link_id.as(String)).limit(1)
link = App::Lib::Database.all(App::Models::Link, query, preload: [:clicks]).first?
raise "Link not found" if link.nil?
link
end end
def delete_test_link(link_id) def delete_test_link(link_id)