28 Commits

Author SHA1 Message Date
sjdonado a271e7c35d chore: bump version 2024-11-27 22:51:52 +01:00
sjdonado a46a50b429 chore: update README with new ENV variables 2024-11-27 22:51:20 +01:00
sjdonado dc8c359bfc test: admin env variables cases 2024-11-27 22:51:18 +01:00
sjdonado dfb6b10caf feat: setup admin user via env variables 2024-11-27 22:27:17 +01:00
Juan Rodriguez 3fa30b3a32 chore: Update README.md 2024-10-27 13:01:40 +01:00
Juan Rodriguez 80ed6033d1 chore: update README.md 2024-10-27 12:56:06 +01:00
Juan Rodriguez 4640522d5d chore: refactor create_links with curl parallel 2024-10-27 12:55:38 +01:00
Juan Rodriguez 848232cc11 fix: create links pipe ipc 2024-10-27 12:01:38 +01:00
Juan Rodriguez 98dedc4494 refactor: powered_by_header kemal config 2024-10-27 11:07:59 +01:00
Juan Rodriguez e6f64ea026 chore: update benchmark with bombardier 2024-10-27 11:07:23 +01:00
Juan Rodriguez ea71d3825e fix: generate slug by user + check existing link on update 2024-07-31 22:09:24 +02:00
Juan Rodriguez afa9b33568 tests: update error messages assertions 2024-07-31 21:50:35 +02:00
Juan Rodriguez a93189411b fix: test suite drop database before all 2024-07-31 21:39:24 +02:00
Juan Rodriguez 98f103f5cf fix: url validate format 2024-07-31 21:38:57 +02:00
Juan Rodriguez 6fc48dae83 refactor: replace slug generation with CRC32 + base62 2024-07-31 21:38:36 +02:00
Juan Rodriguez d039add340 chore: bump version 2024-07-31 08:08:38 +02:00
Juan Rodriguez 0214d6f46d ci: fix publish extract version step 2024-07-31 08:08:15 +02:00
Juan Rodriguez 37e14ec2f8 refactor: sha256 slug generation 2024-07-31 08:07:08 +02:00
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
21 changed files with 503 additions and 335 deletions
-1
View File
@@ -1,6 +1,5 @@
.git .git
/bin/ /bin/
/.shards/ /.shards/
/bruno/
/spec/ /spec/
/sqlite/ /sqlite/
+12 -6
View File
@@ -35,20 +35,26 @@ jobs:
- name: Extract version from shard.yml - name: Extract version from shard.yml
id: extract_version id: extract_version
run: | run: |
VERSION=$(grep -oP 'version:\s*\K\S+' shard.yml) VERSION=$(grep '^version:' shard.yml | cut -d ' ' -f 2)
VERSION=$(echo $VERSION | tr -d '\n\r')
echo "RELEASE_TAG=$VERSION" >> $GITHUB_ENV 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
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: | tags: sjdonado/bit:${{ env.TAGS }}
sjdonado/bit:latest
${{ github.event_name == 'release' && env.RELEASE_TAG && 'sjdonado/bit:${{ env.RELEASE_TAG }}' || '' }}
- name: Attest - name: Attest
uses: actions/attest-build-provenance@v1 uses: actions/attest-build-provenance@v1
+19 -9
View File
@@ -1,5 +1,6 @@
FROM alpine:edge as base FROM alpine:edge AS build
ENV ENV=production
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk update && apk add --no-cache \ RUN apk update && apk add --no-cache \
@@ -9,20 +10,29 @@ RUN apk update && apk add --no-cache \
sqlite-dev \ sqlite-dev \
openssl-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/data data
COPY --from=build /usr/src/app/bin /usr/local/bin COPY --from=build /usr/src/app/bin /usr/local/bin
COPY --from=build /usr/src/app/data /usr/local/data
EXPOSE 4000/tcp EXPOSE 4000/tcp
CMD ["bit"] CMD ["bit"]
+133 -137
View File
@@ -1,103 +1,14 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit/general) [![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit)
[![Docker Stars](https://img.shields.io/docker/stars/sjdonado/bit.svg)](https://hub.docker.com/repository/docker/sjdonado/bit/general) [![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/general) [![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/repository/docker/sjdonado/bit)
# Benchmark
```shell
$ ./benchmark.sh
Semaphore initialized with 2666 slots.
Setup...
[+] Running 2/2
✔ Network bit_default Created 0.0s
✔ Container bit-app-1 Started 0.2s
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...
Creating 1000 short links...
Created short link 100/1000
Created short link 200/1000
Created short link 300/1000
Created short link 400/1000
Created short link 500/1000
Created short link 600/1000
Created short link 700/1000
Created short link 800/1000
Created short link 900/1000
Created short link 1000/1000
Accessing each link 10 times concurrently...
****Results****
Average Memory Usage: 16.36 MiB
Average CPU Usage: 0%
Average Response Time: 12.37 µs
```
# Self-hosted
- Run via docker-compose
```bash
docker-compose up
docker-compose exec -it app migrate
docker-compose exec -it app cli --create-user=Admin
```
- Run via docker cli
```bash
docker run \
--name bit \
-p 4000:4000 \
-e ENV="production" \
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
-e APP_URL="http://localhost:4000" \
sjdonado/bit
docker exec -it bit migrate
docker exec -it bit cli --create-user=Admin
```
- Dokku
```dockerfile
FROM sjdonado/bit
```
```bash
dokku apps:create bit
dokku domains:set bit bit.donado.co
dokku letsencrypt:enable bit
dokku storage:ensure-directory bit-sqlite
dokku storage:mount bit /var/lib/dokku/data/storage/bit-sqlite:/usr/src/app/sqlite/
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 bit http:80:4000
dokku ports:add bit https:443:4000
dokku run bit migrate
dokku run bit cli --create-user=Admin
```
# Usage
## API Endpoints ## API Endpoints
1. **Ping the API** 1. **Ping the API**
- **Endpoint**: `/api/ping` - Endpoint: `GET /api/ping`
- **HTTP Method**: GET - Payload: None
- **Description**: Ping the API to check if it's running - Response Example
- **Payload**: -
- **Response Example**:
```json ```json
{ {
"message": "pong" "message": "pong"
@@ -106,12 +17,10 @@ dokku run bit cli --create-user=Admin
2. **Retrieve a link by its slug** 2. **Retrieve a link by its slug**
- **Endpoint**: `/:slug` - Endpoint: `GET /:slug`
- **HTTP Method**: GET - Headers: `X-Api-Key`
- **Description**: Retrieve a link by its slug - Payload: None
- **Payload**: - - Response Example
- **Headers**: `X-Api-Key`
- **Response Example**:
```json ```json
{ {
"data": { "data": {
@@ -135,12 +44,10 @@ dokku run bit cli --create-user=Admin
3. **Retrieve all links** 3. **Retrieve all links**
- **Endpoint**: `/api/links` - Endpoint: `GET /api/links`
- **HTTP Method**: GET - Headers: `X-Api-Key`
- **Description**: Retrieve all links - Payload: None
- **Payload**: - - Response Example
- **Headers**: `X-Api-Key`
- **Response Example**:
```json ```json
{ {
"data": [ "data": [
@@ -166,12 +73,10 @@ dokku run bit cli --create-user=Admin
4. **Retrieve a link by its ID** 4. **Retrieve a link by its ID**
- **Endpoint**: `/api/links/:id` - Endpoint: `GET /api/links/:id`
- **HTTP Method**: GET - Headers: `X-Api-Key`
- **Description**: Retrieve a link by its ID - Payload: None
- **Payload**: - - Response Example
- **Headers**: `X-Api-Key`
- **Response Example**:
```json ```json
{ {
"data": { "data": {
@@ -195,17 +100,15 @@ dokku run bit cli --create-user=Admin
5. **Create a new link** 5. **Create a new link**
- **Endpoint**: `/api/links` - Endpoint\*\*: `POST /api/links`
- **HTTP Method**: POST - Payload:
- **Description**: Create a new link
- **Payload**:
```json ```json
{ {
"url": "https://example.com" "url": "https://example.com"
} }
``` ```
- **Headers**: `X-Api-Key` - Headers: `X-Api-Key`
- **Response Example**: - Response Example:
```json ```json
{ {
"data": { "data": {
@@ -219,17 +122,15 @@ dokku run bit cli --create-user=Admin
6. **Update an existing link by its ID** 6. **Update an existing link by its ID**
- **Endpoint**: `/api/links/:id` - Endpoint: `PUT /api/links/:id`
- **HTTP Method**: PUT - Payload:
- **Description**: Update an existing link by its ID
- **Payload**:
```json ```json
{ {
"url": "https://newexample.com" "url": "https://newexample.com"
} }
``` ```
- **Headers**: `X-Api-Key` - Headers: `X-Api-Key`
- **Response Example**: - Response Example:
```json ```json
{ {
"data": { "data": {
@@ -243,18 +144,68 @@ dokku run bit cli --create-user=Admin
7. **Delete a link by its ID** 7. **Delete a link by its ID**
- **Endpoint**: `/api/links/:id` - Endpoint: `DELETE /api/links/:id`
- **HTTP Method**: DELETE - Payload: None
- **Description**: Delete a link by its ID - Headers: `X-Api-Key`
- **Payload**: - - Response Example:
- **Headers**: `X-Api-Key`
- **Response Example**:
```json ```json
{ {
"message": "Link deleted" "message": "Link deleted"
} }
``` ```
## Self-hosted
### Run via docker-compose
```bash
docker-compose up
# Optional: Generate an api key
# docker-compose exec -it app cli --create-user=Admin
```
### Run via docker cli
```bash
docker run \
--name bit \
-p 4000:4000 \
-e ENV="production" \
-e DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" \
-e APP_URL="http://localhost:4000" \
-e ADMIN_NAME="Admin" \
-e ADMIN_API_KEY=$(openssl rand -base64 32) \
sjdonado/bit
# Optional: Generate an api key
# docker exec -it bit cli --create-user=Admin
```
### Dokku
```dockerfile
FROM sjdonado/bit
```
```bash
dokku apps:create bit
dokku domains:set bit bit.donado.co
dokku letsencrypt:enable bit
dokku storage:ensure-directory bit-sqlite
dokku storage:mount bit /var/lib/dokku/data/storage/bit-sqlite:/usr/src/app/sqlite/
dokku config:set bit DATABASE_URL="sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true" APP_URL=https://bit.donado.co ADMIN_NAME=Admin ADMIN_API_KEY=$(openssl rand -base64 32)
dokku ports:add bit http:80:4000
dokku ports:add bit https:443:4000
# Optional: Generate an api key
# dokku run bit cli --create-user=Admin
```
## CLI ## CLI
``` ```
@@ -265,33 +216,78 @@ Options:
--delete-user=USER_ID Delete a user by ID --delete-user=USER_ID Delete a user by ID
``` ```
# Development ## Benchmark
1. **Installation** ```
$ ./benchmark.sh
Setting up...
[+] Running 3/3
✔ Network bit_default Created 0.0s
✔ Volume "bit_sqlite_data" Created 0.0s
✔ Container bit Started 0.1s
Captured API Key: aHOCnZSuo2kOHy2mDa-iOA
Waiting for the application to be ready...
HTTP/1.1 200 OK
Connection: keep-alive
Content-Type: application/json
Date: Sun, 27 Oct 2024 11:52:33 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept, Origin, X-Api-Key
Content-Length: 13
Starting resource usage monitoring...
Creating 10000 short links with 100 conrurrent requests...
Link creation complete: 10000 links created.
Fetching all created links from /api/links...
Selected link for benchmarking: http://localhost:4000/UaVZjA
Starting benchmark with Bombardier...
Bombarding http://localhost:4000/oEKLAg with 10000 request(s) using 100 connection(s)
10000 / 10000 [===============================================================================] 100.00% 830/s 12s
Done!
Statistics Avg Stdev Max
Reqs/sec 853.89 1625.49 8942.54
Latency 118.48ms 11.52ms 142.58ms
HTTP codes:
1xx - 0, 2xx - 0, 3xx - 10000, 4xx - 0, 5xx - 0
others - 0
Throughput: 360.02KB/s
Benchmark completed.
Analyzing resource usage...
**** Results ****
Average CPU Usage: 40.68%
Average Memory Usage: 28.62 MiB
./benchmark.sh: line 135: 61567 Terminated: 15 monitor_resource_usage
[+] Running 2/2
✔ Container bit Removed 10.1s
✔ Network bit_default Removed
```
## Development
- Setup
```bash ```bash
brew tap amberframework/micrate brew tap amberframework/micrate
brew install micrate brew install micrate
``` ```
```bash ```bash
shards run migrate
shards run bit shards run bit
``` ```
2. **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/bit/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`)
+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 %}
+2
View File
@@ -3,3 +3,5 @@ require "kemal"
Kemal.config.env = ENV["ENV"]? || "development" Kemal.config.env = ENV["ENV"]? || "development"
Kemal.config.port = ENV["PORT"]?.try(&.to_i) || 4000 Kemal.config.port = ENV["PORT"]?.try(&.to_i) || 4000
Kemal.config.host_binding = ENV["HOST"]? || "0.0.0.0" Kemal.config.host_binding = ENV["HOST"]? || "0.0.0.0"
Kemal.config.powered_by_header = false
+14 -9
View File
@@ -9,6 +9,7 @@ module App::Controllers::Link
class Create < App::Lib::BaseController class Create < App::Lib::BaseController
include App::Models include App::Models
include App::Lib include App::Lib
include App::Services
def call(env) def call(env)
user = env.get("user").as(User) user = env.get("user").as(User)
@@ -26,14 +27,7 @@ module App::Controllers::Link
link.id = UUID.v4.to_s link.id = UUID.v4.to_s
link.url = url link.url = url
link.user = user link.user = user
link.slug = SlugService.shorten_url(url, user.id.to_s)
loop do
slug = Random::Secure.urlsafe_base64(4).gsub(/[^a-zA-Z0-9]/, "")
if !Database.get_by(Link, slug: slug)
link.slug = slug
break
end
end
changeset = Database.insert(link) changeset = Database.insert(link)
if !changeset.valid? if !changeset.valid?
@@ -124,6 +118,7 @@ module App::Controllers::Link
class Update < App::Lib::BaseController class Update < App::Lib::BaseController
include App::Models include App::Models
include App::Lib include App::Lib
include App::Services
def call(env) def call(env)
user = env.get("user").as(User) user = env.get("user").as(User)
@@ -136,7 +131,17 @@ module App::Controllers::Link
raise App::NotFoundException.new(env) if link.nil? raise App::NotFoundException.new(env) if link.nil?
raise App::ForbiddenException.new(env) if link.user_id != user.id raise App::ForbiddenException.new(env) if link.user_id != user.id
link.url = body["url"].to_s new_url = body["url"].to_s
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
existing_link = Database.all(Link, existing_query).first?
if existing_link
raise App::UnprocessableEntityException.new(env, { "url" => ["URL already exists"] })
end
link.url = new_url
link.slug = SlugService.shorten_url(new_url, user.id.to_s)
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
+1 -1
View File
@@ -17,6 +17,6 @@ module App::Models
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, /\A(?:(https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,})(?::\d+)?(?:[\/?#]\S*)?\z/i
end end
end end
+20 -2
View File
@@ -3,11 +3,11 @@ require "../lib/*"
require "../models/*" require "../models/*"
module App::Services::Cli module App::Services::Cli
def self.create_user(name) def self.create_user(name, api_key = nil)
user = App::Models::User.new user = App::Models::User.new
user.id = UUID.v4.to_s user.id = UUID.v4.to_s
user.name = name user.name = name
user.api_key = Random::Secure.urlsafe_base64() user.api_key = api_key || Random::Secure.urlsafe_base64()
changeset = App::Lib::Database.insert(user) changeset = App::Lib::Database.insert(user)
return changeset.errors if !changeset.valid? return changeset.errors if !changeset.valid?
@@ -35,4 +35,22 @@ module App::Services::Cli
"User with ID #{user_id} deleted successfully" "User with ID #{user_id} deleted successfully"
end end
def self.setup_admin_user
admin_name = ENV["ADMIN_NAME"]?
admin_api_key = ENV["ADMIN_API_KEY"]?
if admin_name && admin_api_key
query = App::Lib::Database::Query.where(name: admin_name, api_key: admin_api_key).limit(1)
existing_user = App::Lib::Database.all(App::Models::User, query).first?
return if existing_user
puts "Admin user setup detected. Creating admin user..."
result = create_user(admin_name, admin_api_key)
puts result
else
puts "Admin setup skipped: Missing ADMIN_NAME or ADMIN_API_KEY environment variables."
end
end
end end
+12
View File
@@ -0,0 +1,12 @@
require "digest"
require "base64"
module App::Services::SlugService
def self.shorten_url(url : String, user_id : String) : String
combined = "#{user_id}-#{url}"
crc32_hash = Digest::CRC32.digest(combined)
base62_encoded = Base64.urlsafe_encode(crc32_hash).strip.tr("-_=", "")
base62_encoded
end
end
+128 -126
View File
@@ -1,154 +1,156 @@
#!/bin/bash #!/bin/bash
api_url="http://localhost:4001/api/links" # Configuration variables
num_links=1000 server_url="http://localhost:4000"
num_requests=10 api_url="${server_url}/api/links"
resource_usage_interval=1 # Interval in seconds for resource usage logging num_links=10000
num_requests=10000
concurrency=100
resource_usage_interval=1
container_name="bit"
semaphore="/tmp/semaphore" check_dependencies() {
max_concurrent_processes=$(ulimit -u) # Adjust this number based on your system's capability if ! command -v bombardier &> /dev/null; then
echo "Error: bombardier is not installed. Please install it to proceed."
exit 1
fi
# Initialize semaphore if ! command -v jq &> /dev/null; then
mkfifo $semaphore echo "Error: jq is not installed. Please install it to proceed."
exec 3<> $semaphore exit 1
rm $semaphore fi
}
for ((i=0; i<max_concurrent_processes; i++)); do setup_containers() {
echo >&3 echo "Setting up..."
done docker compose up -d
if [ $? -ne 0 ]; then
echo "Failed to start Docker containers."
exit 1
fi
echo "Semaphore initialized with $max_concurrent_processes slots." output=$(docker compose exec -T app cli --create-user=Admin)
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
echo "Captured API Key: $api_key"
function get_resource_usage { if [[ -z "$api_key" ]]; then
while true; do echo "Error: API key could not be retrieved."
docker stats --no-stream --format "table {{.MemUsage}} {{.CPUPerc}}" bit-app-1 | awk 'NR>1 {print "Memory:", $1, "CPU:", $2}' >> resource_usage.txt exit 1
fi
echo "Waiting for the application to be ready..."
until curl --silent --head --fail --header "X-Api-Key: $api_key" "$server_url/api/ping"; do
sleep 2
done
}
monitor_resource_usage() {
echo "Starting resource usage monitoring..."
echo "Timestamp,CPU,Memory" > resource_usage.csv
while :; do
stats=$(docker stats --no-stream --format "{{.CPUPerc}},{{.MemUsage}}" $container_name)
cpu=$(echo $stats | awk -F',' '{print $1}' | sed 's/%//')
mem=$(echo $stats | awk -F',' '{print $2}' | awk '{print $1}')
timestamp=$(date +%s)
echo "$timestamp,$cpu,$mem" >> resource_usage.csv
sleep $resource_usage_interval sleep $resource_usage_interval
done done
} }
function calculate_average_usage { create_links() {
total_mem=0 local temp_file=$(mktemp)
total_cpu=0
count=0
while read -r line; do echo "Creating $num_links short links with $concurrency conrurrent requests..."
if echo $line | grep -q 'Memory'; then
mem=$(echo $line | awk '{print $2}' | sed 's/MiB//')
total_mem=$(echo "$total_mem + $mem" | bc)
elif echo $line | grep -q 'CPU'; then
cpu=$(echo $line | awk '{print $2}' | sed 's/%//')
total_cpu=$(echo "$total_cpu + $cpu" | bc)
fi
((count++))
done < resource_usage.txt
avg_mem=$(echo "scale=2; $total_mem / ($count / 2)" | bc) # Since there are 2 lines per interval # Populate URLs into a file to feed into curl
avg_cpu=$(echo "scale=2; $total_cpu / ($count / 2)" | bc) for ((i=1; i<=num_links; i++)); do
rm resource_usage.txt url="https://example.com/${i}-${num_links}"
echo "--next" >> "$temp_file"
echo "--request POST" >> "$temp_file"
echo "--url \"$api_url\"" >> "$temp_file"
echo "--header \"X-Api-Key: $api_key\"" >> "$temp_file"
echo "--header \"Content-Type: application/json\"" >> "$temp_file"
echo "--data \"{ \\\"url\\\": \\\"$url\\\" }\"" >> "$temp_file"
done
echo "Average Memory Usage: $avg_mem MiB" curl --parallel --parallel-immediate --parallel-max $concurrency --config "$temp_file" --silent --write-out "%{http_code}\n" > /dev/null
echo "Average CPU Usage: $avg_cpu%"
echo "Link creation complete: $num_links links created."
# Clean up
rm -f "$temp_file"
} }
function measure { run_benchmark() {
total_time=0 echo "Fetching all created links from /api/links..."
declare -a refer_links all_links_response=$(curl --silent --request GET \
--url "$api_url" \
--header "X-Api-Key: $api_key" \
--header "Content-Type: application/json")
# Start resource usage logging in the background links=($(echo "$all_links_response" | jq -r '.data[] | .refer'))
nohup bash -c "$(declare -f get_resource_usage); get_resource_usage" &> /dev/null & if [[ ${#links[@]} -ne $num_links ]]; then
resource_usage_pid=$! echo "Error: Expected $num_links links but found ${#links[@]}."
disown exit 1
echo "Creating $num_links short links..."
for ((i=1; i<=num_links; i++)); do
response=$(curl --silent --request POST \
--url $api_url \
--header "X-Api-Key: $api_key" \
--header "Content-Type: application/json" \
--data "{ \"url\": \"https://kagi.com\" }")
refer=$(echo $response | awk -F'"' '/"refer":/{print $(NF-1)}')
if [[ -n $refer ]]; then
refer_links+=("$refer")
if (( i % 100 == 0 )); then
echo "Created short link $i/$num_links"
fi
else
echo "Failed to create short link $i"
echo $response
exit 1
fi
done
echo "Accessing each link $num_requests times concurrently..."
> times.txt # Ensure times.txt is created and empty
total_accesses=$((num_links * num_requests))
accesses_done=0
for refer in "${refer_links[@]}"; do
for ((i=1; i<=num_requests; i++)); do
# Wait for a slot
read -u 3
{
start_time=$(date +%s%6N)
curl -s "$refer" >> /dev/null
end_time=$(date +%s%6N)
elapsed_time=$(echo "$end_time - $start_time" | bc)
echo $elapsed_time >> times.txt
# Release the slot
echo >&3
((accesses_done++))
if (( accesses_done % 10 == 0 )); then
echo "Accessed $accesses_done/$total_accesses"
fi
} &
done
done
wait
# Stop resource usage logging
if kill -0 $resource_usage_pid 2>/dev/null; then
kill $resource_usage_pid
fi fi
# Read all elapsed times and calculate total random_link="${links[RANDOM % ${#links[@]}]}"
while read -r time; do echo "Selected link for benchmarking: $random_link"
total_time=$(echo "$total_time + $time" | bc)
done < times.txt
rm times.txt
echo "****Results****" echo "Starting benchmark with Bombardier..."
bombardier -c $concurrency -n $num_requests "$random_link"
calculate_average_usage echo "Benchmark completed."
echo "Average Response Time: $(echo "scale=2; $total_time / ($num_links * $num_requests)" | bc) µs"
} }
echo "Setup..." analyze_resource_usage() {
echo "Analyzing resource usage..."
total_cpu=0
total_mem=0
count=0
docker-compose up -d while IFS=',' read -r timestamp cpu mem; do
if [ $? -ne 0 ]; then # Skip header line and lines with empty cpu or mem values
echo "Failed to start Docker containers." if [[ $timestamp != "Timestamp" && -n $cpu && -n $mem ]]; then
exit 1 mem=${mem%MiB}
fi
docker-compose exec -T app migrate total_cpu=$(echo "$total_cpu + $cpu" | bc)
if [ $? -ne 0 ]; then total_mem=$(echo "$total_mem + $mem" | bc)
echo "Failed to run database migrations." ((count++))
exit 1 fi
fi done < resource_usage.csv
# Create a new user and capture the API key avg_cpu=0.00
output=$(docker-compose exec -T app cli --create-user=Admin) avg_mem=0.00
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
echo "Captured API Key: $api_key"
echo "Waiting for database to be ready..." if (( count > 0 )); then
sleep 5 avg_cpu=$(echo "scale=2; $total_cpu / $count" | bc)
avg_mem=$(echo "scale=2; $total_mem / $count" | bc)
fi
measure echo "**** Results ****"
echo "Average CPU Usage: $avg_cpu%"
echo "Average Memory Usage: $avg_mem MiB"
}
# Clean up cleanup() {
docker-compose down rm -f resource_usage.csv
docker compose down
}
main() {
check_dependencies
setup_containers
monitor_resource_usage & # Start monitoring in the background
monitor_pid=$!
trap 'kill $monitor_pid; cleanup; exit' INT
create_links
run_benchmark
kill $monitor_pid
analyze_resource_usage
cleanup
}
main
+2 -3
View File
@@ -5,14 +5,13 @@ require "./app/lib/*"
require "./app/models/*" require "./app/models/*"
require "./app/serializers/*" require "./app/serializers/*"
require "./app/middlewares/*" require "./app/middlewares/*"
require "./app/services/*"
require "./app/routes" 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} App::Services::Cli.setup_admin_user
error 401 { |env| {"error" => "Unauthorized" }.to_json}
error 404 { |env| {"error" => "Not Found" }.to_json}
Kemal.run Kemal.run
@@ -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;
+9 -3
View File
@@ -1,9 +1,15 @@
services: services:
app: app:
container_name: bit
build: . build: .
environment: environment:
ENV: production ENV: production
DATABASE_URL: sqlite3://./sqlite/data.db?journal_mode=wal&synchronous=normal&foreign_keys=true ADMIN_NAME: 'Tester'
APP_URL: http://0.0.0.0:4001 ADMIN_API_KEY: '0p+mDvbpZGLPGVCXnV+EDduR9Blkv27Dhq9XSzSbdQY='
ports: ports:
- 4001:4000 - 4000:4000
volumes:
- sqlite_data:/app/sqlite
volumes:
sqlite_data:
-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
+1 -3
View File
@@ -1,5 +1,5 @@
name: bit name: bit
version: 1.1.0 version: 1.4.0
authors: authors:
- Juan Rodriguez <sjdonado@icloud.com> - Juan Rodriguez <sjdonado@icloud.com>
@@ -9,8 +9,6 @@ targets:
main: bit.cr main: bit.cr
cli: cli:
main: scripts/cli.cr main: scripts/cli.cr
migrate:
main: scripts/migrate.cr
dependencies: dependencies:
kemal: kemal:
+18 -24
View File
@@ -22,7 +22,7 @@ describe "App::Controllers::Link" do
it "should return existing link if url already exists" do it "should return existing link if url already exists" do
test_user = create_test_user() test_user = create_test_user()
payload = {"url" => "https://kagi.com"} payload = {"url" => "http://idonthavespotify.donado.co"}
post( post(
"/api/links", "/api/links",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s}, headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
@@ -75,7 +75,7 @@ describe "App::Controllers::Link" do
payload = {"url" => "https://kagi.com"} payload = {"url" => "https://kagi.com"}
post("/api/links", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: payload.to_json) post("/api/links", headers: HTTP::Headers{"Content-Type" => "application/json"}, body: payload.to_json)
expected = {"error" => "Unauthorized"}.to_json expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401) response.status_code.should eq(401)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -83,7 +83,7 @@ describe "App::Controllers::Link" do
describe "Index" do describe "Index" do
it "should redirect to origin domain" do it "should redirect to origin domain" do
link = "https://kagi.com" link = "https://test.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)
@@ -95,7 +95,7 @@ describe "App::Controllers::Link" do
end end
it "should create a new click after redirect" do it "should create a new click after redirect" do
link = "https://kagi.com" link = "https://sjdonado.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)
@@ -112,17 +112,11 @@ describe "App::Controllers::Link" do
end end
it "should return 404 - link does not exist" do it "should return 404 - link does not exist" do
link = "https://kagi.com"
test_user = create_test_user() test_user = create_test_user()
test_link = create_test_link(test_user, link) get("https://localhost:4001/R4kj2")
serialized_link = App::Serializers::Link.new(test_link)
delete_test_link(test_link.id) expected = {"error" => "Resource not found"}.to_json
get(serialized_link.refer, 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.status_code.should eq(404)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -146,7 +140,7 @@ describe "App::Controllers::Link" do
end end
it "should return owned links only" do it "should return owned links only" do
links = ["https://google.com", "google.com", "google.com.co", "kagi.com"] links = ["https://google.de", "google.de", "google.edu.co", "x.com"]
test_user = create_test_user() test_user = create_test_user()
links[0..2].each do |link| links[0..2].each do |link|
@@ -168,7 +162,7 @@ describe "App::Controllers::Link" do
it "should return 401 - missing api key" do it "should return 401 - missing api key" do
get "/api/links" get "/api/links"
expected = {"error" => "Unauthorized"}.to_json expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401) response.status_code.should eq(401)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -176,7 +170,7 @@ describe "App::Controllers::Link" do
describe "Get" do describe "Get" do
it "should return the specified link with click details" do it "should return the specified link with click details" do
link = "https://kagi.com" link = "https://bing.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)
@@ -192,7 +186,7 @@ describe "App::Controllers::Link" do
get("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) get("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
expected = {"error" => "Not Found"}.to_json expected = {"error" => "Resource not found"}.to_json
response.status_code.should eq(404) response.status_code.should eq(404)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -200,7 +194,7 @@ describe "App::Controllers::Link" do
it "should return 401 - missing api key" do it "should return 401 - missing api key" do
get "/api/links/1" get "/api/links/1"
expected = {"error" => "Unauthorized"}.to_json expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401) response.status_code.should eq(401)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -208,11 +202,11 @@ describe "App::Controllers::Link" do
describe "Update" do describe "Update" do
it "should update link url" do it "should update link url" do
link = "https://kagi.com" link = "https://github.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)
payload = {"url" => "https://kagi.com.co"} payload = {"url" => "https://github.com.co"}
put( put(
"/api/links/#{test_link.id}", "/api/links/#{test_link.id}",
headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s}, headers: HTTP::Headers{"Content-Type" => "application/json", "X-Api-Key" => test_user.api_key.to_s},
@@ -233,7 +227,7 @@ describe "App::Controllers::Link" do
body: payload.to_json body: payload.to_json
) )
expected = {"error" => "Not Found"}.to_json expected = {"error" => "Resource not found"}.to_json
response.status_code.should eq(404) response.status_code.should eq(404)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -246,7 +240,7 @@ describe "App::Controllers::Link" do
body: payload.to_json body: payload.to_json
) )
expected = {"error" => "Unauthorized"}.to_json expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401) response.status_code.should eq(401)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -254,7 +248,7 @@ describe "App::Controllers::Link" do
describe "Delete" do describe "Delete" do
it "should delete link url" do it "should delete link url" do
link = "https://kagi.com" link = "https://news.ycombinator.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)
@@ -268,7 +262,7 @@ describe "App::Controllers::Link" do
delete("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s}) delete("/api/links/1", headers: HTTP::Headers{"X-Api-Key" => test_user.api_key.to_s})
expected = {"error" => "Not Found"}.to_json expected = {"error" => "Resource not found"}.to_json
response.status_code.should eq(404) response.status_code.should eq(404)
response.body.should eq(expected) response.body.should eq(expected)
end end
@@ -276,7 +270,7 @@ describe "App::Controllers::Link" do
it "should return 401 - missing api key" do it "should return 401 - missing api key" do
delete "/api/links/1" delete "/api/links/1"
expected = {"error" => "Unauthorized"}.to_json expected = {"error" => "Unauthorized access"}.to_json
response.status_code.should eq(401) response.status_code.should eq(401)
response.body.should eq(expected) response.body.should eq(expected)
end end
+25
View File
@@ -34,4 +34,29 @@ describe "App::Services::Cli" do
output.should contain "Failed to delete user" output.should contain "Failed to delete user"
end end
it "sets up an admin user if environment variables are present" do
ENV["ADMIN_NAME"] = "adminuser"
ENV["ADMIN_API_KEY"] = "secure_admin_key"
App::Services::Cli.setup_admin_user
admin_user = App::Lib::Database.all(App::Models::User).find { |u| u.name == "adminuser" }
admin_user.should_not be_nil
admin_user = admin_user.not_nil!
admin_user.api_key.should eq "secure_admin_key"
App::Services::Cli.delete_user(admin_user.id)
end
it "skips admin setup if environment variables are missing" do
ENV.delete("ADMIN_NAME")
ENV.delete("ADMIN_API_KEY")
App::Services::Cli.setup_admin_user
users = App::Lib::Database.all(App::Models::User)
users.none? { |u| u.name == "adminuser" }.should be_true
end
end end
+16 -4
View File
@@ -1,11 +1,21 @@
require "uuid" require "uuid"
require "file_utils"
require "spec-kemal" require "spec-kemal"
require "micrate" require "micrate"
require "dotenv"
Dotenv.load ".env.#{ENV["ENV"]}"
require "../bit" require "../bit"
Spec.before_suite do Spec.before_suite do
# Delete the SQLite database file if it exists
db_file_path = ENV["DATABASE_URL"].split("sqlite3://").last.split("?").first
if File.exists?(db_file_path)
File.delete(db_file_path)
end
Micrate::DB.connection_url = ENV["DATABASE_URL"] Micrate::DB.connection_url = ENV["DATABASE_URL"]
Micrate::Cli.run_up Micrate::Cli.run_up
@@ -20,7 +30,8 @@ def create_test_user
changeset = App::Lib::Database.insert(user) changeset = App::Lib::Database.insert(user)
if !changeset.valid? if !changeset.valid?
raise "Test user creation failed" error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
raise "Test user creation failed #{error_messages}"
end end
user user
@@ -29,13 +40,14 @@ end
def create_test_link(user, url) def create_test_link(user, url)
link = App::Models::Link.new link = App::Models::Link.new
link.id = UUID.v4.to_s link.id = UUID.v4.to_s
link.slug = App::Services::SlugService.shorten_url(url, user.id.to_s)
link.url = url link.url = url
link.slug = Random::Secure.urlsafe_base64(4)
link.user = user link.user = user
changeset = App::Lib::Database.insert(link) changeset = App::Lib::Database.insert(link)
if !changeset.valid? unless changeset.valid?
raise "Test link creation failed" error_messages = changeset.errors.map { |error| "#{error}" }.join(", ")
raise "Test link creation failed: #{error_messages}"
end end
link.clicks = [] of App::Models::Click link.clicks = [] of App::Models::Click