60 Commits

Author SHA1 Message Date
sjdonado 30fb539289 ci: speed up build with crystal arm64 binary 2025-02-07 19:37:54 +01:00
sjdonado 66fd6db3c2 ci: docker hub release tags 2025-02-07 19:05:31 +01:00
sjdonado 93a91cd76e ci: publish github workflow separate version tags from master 2025-02-07 18:53:55 +01:00
sjdonado 1d1444234b ci: publish workflow extract tag version 2025-02-07 18:44:49 +01:00
sjdonado 49ac63210e chore: bump version 2025-02-07 17:51:55 +01:00
sjdonado 702491cb39 chore: improve documentation local development guidelines 2025-02-07 17:50:10 +01:00
Juan Rodriguez d55dbe0471 Merge pull request #4 from sjdonado/chore/documentation
Chore/documentation
2025-02-07 17:34:25 +01:00
sjdonado 55969b03b5 chore: fix docs broken links 2025-02-07 17:31:05 +01:00
sjdonado 70a036e158 chore: cleanup README 2025-02-07 17:23:10 +01:00
sjdonado 6ade7d295b chore: CONTRIBUTING and CODE_OF_CONDUCT 2025-02-07 17:22:54 +01:00
sjdonado e6ae133449 chore: separate API reference from README 2025-02-07 17:22:41 +01:00
sjdonado d3706a8778 chore: separate SETUP docs from README 2025-02-07 17:22:22 +01:00
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
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
35 changed files with 7118 additions and 353 deletions
-1
View File
@@ -1,6 +1,5 @@
.git .git
/bin/ /bin/
/.shards/ /.shards/
/bruno/
/spec/ /spec/
/sqlite/ /sqlite/
+45 -23
View File
@@ -1,4 +1,4 @@
name: Publish Docker image name: Publish Docker images
on: on:
push: push:
@@ -8,43 +8,65 @@ on:
types: [published] types: [published]
jobs: jobs:
push_to_registry: build-platforms:
name: Push Docker image to Docker Hub name: Build Platforms
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
platform: [linux/amd64, linux/arm64]
permissions: permissions:
packages: write
contents: read contents: read
attestations: write packages: write
id-token: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
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
if: github.event_name == 'release' id: version
run: echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV run: |
- name: Build and push image VERSION=$(grep '^version:' shard.yml | cut -d ' ' -f 2)
id: push echo "version=$VERSION" >> $GITHUB_OUTPUT
uses: docker/build-push-action@v5.0.0
- name: Build and push platform image
uses: docker/build-push-action@v5
env:
CRYSTAL_WORKERS: ${{ matrix.platform == 'linux/amd64' && 4 || 2 }}
with: with:
context: . context: .
platforms: ${{ matrix.platform }}
push: true push: true
platforms: linux/amd64,linux/arm64
tags: | tags: |
sjdonado/url-shortener:latest sjdonado/bit:${{ github.event_name == 'release' && steps.version.outputs.version || 'latest' }}-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
${{ github.event_name == 'release' && 'sjdonado/url-shortener:${{ env.RELEASE_TAG }}' || '' }} build-args: |
- name: Attest TARGETARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
uses: actions/attest-build-provenance@v1 cache-from: type=gha
id: attest cache-to: type=gha,mode=max
create-manifest:
name: Create Manifest
runs-on: ubuntu-latest
needs: build-platforms
permissions:
packages: write
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
with: with:
subject-name: sjdonado/url-shortener username: ${{ secrets.DOCKERHUB_USERNAME }}
subject-digest: ${{ steps.push.outputs.digest }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest
run: |
docker buildx imagetools create \
-t sjdonado/bit:${{ github.event_name == 'release' && needs.build-platforms.outputs.version || 'latest' }} \
sjdonado/bit:${{ github.event_name == 'release' && needs.build-platforms.outputs.version || 'latest' }}-{amd64,arm64}
+2 -1
View File
@@ -1,4 +1,3 @@
/docs/
/lib/ /lib/
/bin/ /bin/
/.shards/ /.shards/
@@ -7,3 +6,5 @@
/sqlite/ /sqlite/
.env.production .env.production
resource_usage.txt
+132
View File
@@ -0,0 +1,132 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+75
View File
@@ -0,0 +1,75 @@
# Contributing Guidelines
We welcome contributions from the community! Please follow these guidelines to help maintain consistency and quality in the project.
## Code of Conduct
This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you agree to uphold its terms.
## How to Contribute
### 1. Fork the Repository
Click the "Fork" button at the top-right of the [repository page](https://github.com/sjdonado/bit).
### 2. Clone Your Fork
```bash
git clone https://github.com/YOUR_USERNAME/bit.git
cd bit
```
### 3. Create a Feature Branch
```bash
git checkout -b feat/your-feature-name
```
### or for bug fixes:
```bash
git checkout -b fix/issue-description
```
### 4. Develop Your Changes
- Check [Local Development](docs/SETUP.md#local-development) guidelines
- Ensure changes match the project scope
- Write clear commit messages
- Include tests for new functionality
- Update documentation when applicable
### 5. Commit Changes
```bash
git commit -am 'Add descriptive commit message'
```
### 6. Push to GitHub
```bash
git push origin your-branch-name
```
### 7. Create a Pull Request
1. Go to the [original repository](https://github.com/sjdonado/bit)
2. Click "New Pull Request"
3. Select your fork and branch
4. Add a clear description including:
- Purpose of changes
- Related issues (if applicable)
- Testing performed
## Pull Request Guidelines
- Keep PRs focused on a single feature/bugfix
- Ensure all tests pass
- Update documentation in the same PR
- Use descriptive titles (e.g., "Add URL validation" not "Update code")
- Reference related issues using #issue-number
## Reporting Issues
When opening an issue, please include:
1. Description of the problem
2. Steps to reproduce
3. Expected vs actual behavior
4. Environment details (OS, Crystal version, etc)
For feature requests:
- Explain the problem you're trying to solve
- Suggest potential implementations
## License
By contributing, you agree that your contributions will be licensed under the [license](LICENSE).
+37 -9
View File
@@ -1,19 +1,47 @@
FROM alpine:edge as base FROM debian:bookworm-slim AS build
ARG TARGETARCH
ENV ENV=production
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add crystal shards sqlite-dev openssl-dev RUN apt-get update && apt-get install -y \
curl \
gnupg \
ca-certificates \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://packagecloud.io/84codes/crystal/gpgkey | gpg --dearmor > /etc/apt/trusted.gpg.d/84codes_crystal.gpg \
&& echo "deb [signed-by=/etc/apt/trusted.gpg.d/84codes_crystal.gpg] https://packagecloud.io/84codes/crystal/debian/ bookworm main" > /etc/apt/sources.list.d/84codes_crystal.list
RUN apt-get update && apt-get install -y \
crystal \
libssl-dev \
libyaml-dev \
libsqlite3-dev \
libevent-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN shards install --production
RUN shards build --release --no-debug --progress --stats
FROM debian:bookworm-slim AS runtime
FROM base AS build
ENV ENV=production ENV ENV=production
COPY . . WORKDIR /usr/src/app
RUN shards install RUN apt-get update && apt-get install -y \
RUN shards build --progress libssl3 \
libyaml-0-2 \
libsqlite3-0 \
libevent-2.1-7 \
&& rm -rf /var/lib/apt/lists/*
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"]
+19 -129
View File
@@ -1,138 +1,28 @@
# url-shortener [![Docker Pulls](https://img.shields.io/docker/pulls/sjdonado/bit.svg)](https://hub.docker.com/r/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/r/sjdonado/bit)
[![Docker Image Size](https://img.shields.io/docker/image-size/sjdonado/bit/latest)](https://hub.docker.com/r/sjdonado/bit)
## Benchmark # Bit URL Shortener
```shell
./benchmark.sh
Semaphore initialized with 2666 slots.
Setup...
[+] Running 1/0
✔ Container url-shortener-app-1 Running 0.0s
2024-05-20T16:39:53.818306Z INFO - micrate: No migrations to run. current version: 20240513130054
Captured API Key: 4y2mblZDneZLcI-YywHGFA
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: 11.00 MiB
Average Response Time: 5.28 µs
[+] Running 2/2
✔ Container url-shortener-app-1 Removed 10.2s
✔ Network url-shortener_default Removed
```
## Self-hosted Lightweight URL shortener service with minimal resource requirements. Average memory consumption is **20MB RAM** with container disk space under **50MB**.
- Run via docker-compose Bit is highly performant, achieving over 850 requests per second with an average latency of just 118ms. For detailed benchmark results, see [benchmark](docs/SETUP.md#benchmark).
Images available on [Docker Hub](https://hub.docker.com/r/sjdonado/bit/tags).
## Quick Start
```bash ```bash
docker-compose up docker run -p 4000:4000 -e ADMIN_API_KEY=$(openssl rand -base64 32) sjdonado/bit:latest
docker-compose exec -it app migrate
docker-compose exec -it app cli --create-user=Admin
``` ```
- Run via docker cli ## Minimum Requirements
```bash - 50MB disk space
docker run \ - 50MB RAM (20MB avg usage)
--name url-shortener \ - x86_64 or ARM64 architecture
-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/url-shortener
docker exec -it url-shortener migrate ## Documentation
docker exec -it url-shortener cli --create-user=Admin - [API Reference](docs/API.md)
``` - [Advanced Setup](docs/SETUP.md)
- Dokku
```dockerfile
FROM sjdonado/url-shortener
```
```bash
dokku apps:create url-shortener
dokku domains:set url-shortener bit.donado.co
dokku letsencrypt:enable url-shortener
dokku storage:ensure-directory url-shortener-sqlite
dokku storage:mount url-shortener /var/lib/dokku/data/storage/url-shortener-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 ports:add url-shortener http:80:4000
dokku ports:add url-shortener https:443:4000
dokku run url-shortener migrate
dokku run url-shortener cli --create-user=Admin
```
## Usage
**REST API**
| Endpoint | HTTP Method | Description | Payload |
|----------|-------------|-------------|---------|
| `/api/ping` | GET | Ping the API to check if it's running | - |
| `/:slug` | GET | Retrieve a link by its slug | - |
| `/api/links` | GET | Retrieve all links | - |
| `/api/links` | POST | Create a new link | `{"url": "https://example.com"}` |
| `/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 | - |
**CLI**
```
Usage: ./cli [options]
Options:
--create-user=NAME Create a new user with the given name
--list-users List all users
--delete-user=USER_ID Delete a user by ID
```
## Development
**Installation**
```bash
brew tap amberframework/micrate
brew install micrate
```
```bash
shards run migrate
shards run url-shortener
```
**Generate the `X-Api-Key`**
```bash
shards run cli -- --create-user=Admin
```
## Run tests
```bash
ENV=test crystal spec
```
## Contributing ## Contributing
Found an issue or have a suggestion? Please follow our [contribution guidelines](CONTRIBUTING.md).
1. Fork it (<https://github.com/sjdonado/url-shortener/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
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 %}
+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
+68 -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"
@@ -6,23 +9,34 @@ 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)
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
link.slug = SlugService.shorten_url(url, user.id.to_s)
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 +52,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,31 +90,58 @@ 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
include App::Services
def call(env) def call(env)
user = env.get("user").as(User) user = env.get("user").as(User)
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
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 end
link.url = body["url"].to_s link.url = new_url
link.click_counter = 0 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
+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, /\A(?:(https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,})(?::\d+)?(?:[\/?#]\S*)?\z/i
end end
end end
+5 -1
View File
@@ -4,7 +4,7 @@ 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"
end end
after_all do |env| after_all do |env|
@@ -23,6 +23,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
+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 -117
View File
@@ -1,145 +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 "{{.MemUsage}} {{.CPUPerc}}" url-shortener >> 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() {
local temp_file=$(mktemp)
echo "Creating $num_links short links with $concurrency conrurrent requests..."
# Populate URLs into a file to feed into curl
for ((i=1; i<=num_links; i++)); do
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
curl --parallel --parallel-immediate --parallel-max $concurrency --config "$temp_file" --silent --write-out "%{http_code}\n" > /dev/null
echo "Link creation complete: $num_links links created."
# Clean up
rm -f "$temp_file"
}
run_benchmark() {
echo "Fetching all created links from /api/links..."
all_links_response=$(curl --silent --request GET \
--url "$api_url" \
--header "X-Api-Key: $api_key" \
--header "Content-Type: application/json")
links=($(echo "$all_links_response" | jq -r '.data[] | .refer'))
if [[ ${#links[@]} -ne $num_links ]]; then
echo "Error: Expected $num_links links but found ${#links[@]}."
exit 1
fi
random_link="${links[RANDOM % ${#links[@]}]}"
echo "Selected link for benchmarking: $random_link"
echo "Starting benchmark with Bombardier..."
bombardier -c $concurrency -n $num_requests "$random_link"
echo "Benchmark completed."
}
analyze_resource_usage() {
echo "Analyzing resource usage..."
total_cpu=0
total_mem=0 total_mem=0
count=0 count=0
while read -r line; do while IFS=',' read -r timestamp cpu mem; do
mem=$(echo $line | awk '{print $1}') # Skip header line and lines with empty cpu or mem values
if [[ $timestamp != "Timestamp" && -n $cpu && -n $mem ]]; then
mem=${mem%MiB}
# Convert memory to MiB if necessary total_cpu=$(echo "$total_cpu + $cpu" | bc)
if [[ $mem == *MiB ]]; then total_mem=$(echo "$total_mem + $mem" | bc)
mem=$(echo $mem | sed 's/MiB//') ((count++))
elif [[ $mem == *GiB ]]; then
mem=$(echo $mem | sed 's/GiB//')
mem=$(echo "$mem * 1024" | bc)
fi fi
done < resource_usage.csv
total_mem=$(echo "$total_mem + $mem" | bc) avg_cpu=0.00
((count++)) avg_mem=0.00
done < resource_usage.txt
avg_mem=$(echo "scale=2; $total_mem / $count" | bc) if (( count > 0 )); then
rm resource_usage.txt avg_cpu=$(echo "scale=2; $total_cpu / $count" | bc)
avg_mem=$(echo "scale=2; $total_mem / $count" | bc)
fi
echo "**** Results ****"
echo "Average CPU Usage: $avg_cpu%"
echo "Average Memory Usage: $avg_mem MiB" echo "Average Memory Usage: $avg_mem MiB"
} }
function measure { cleanup() {
total_time=0 rm -f resource_usage.csv
declare -a refer_links docker compose down
# Start resource usage logging in the background
nohup bash -c "$(declare -f get_resource_usage); get_resource_usage" &> /dev/null &
resource_usage_pid=$!
disown
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"
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
# Read all elapsed times and calculate total
while read -r time; do
total_time=$(echo "$total_time + $time" | bc)
done < times.txt
rm times.txt
echo "****Results****"
calculate_average_usage
echo "Average Response Time: $(echo "scale=2; $total_time / ($num_links * $num_requests)" | bc) µs"
} }
echo "Setup..." main() {
check_dependencies
setup_containers
docker-compose up -d monitor_resource_usage & # Start monitoring in the background
# Ensure migrations are done monitor_pid=$!
docker-compose exec -T app migrate trap 'kill $monitor_pid; cleanup; exit' INT
# Create a new user and capture the API key create_links
output=$(docker-compose exec -T app cli --create-user=Admin) run_benchmark
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
echo "Captured API Key: $api_key"
echo "Waiting for database to be ready..." kill $monitor_pid
sleep 5 analyze_resource_usage
cleanup
}
measure main
# Clean up
docker-compose down
+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
+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;
+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:
+152
View File
@@ -0,0 +1,152 @@
# API Reference
1. **Ping the API**
- Endpoint: `GET /api/ping`
- Payload: None
- Response Example
```json
{
"message": "pong"
}
```
2. **Redirect by 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. **List 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. **List link by 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 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 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 ID**
- Endpoint: `DELETE /api/links/:id`
- Payload: None
- Headers: `X-Api-Key`
- Response Example:
```json
{
"message": "Link deleted"
}
```
+147
View File
@@ -0,0 +1,147 @@
## CLI
```
Usage: ./cli [options]
Options:
--create-user=NAME Create a new user with the given name
--list-users List all users
--delete-user=USER_ID Delete a user by ID
```
## Run It Anywhere
### Docker Compose
```bash
docker-compose up
# Optional: Generate an api key
# docker-compose exec -it app cli --create-user=Admin
```
### 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
```
### Self-Hosted with 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
```
## Local Development
### Requirements
- Crystal 1.12+
- Shards package manager
- SQLite3
### Install Dependencies
- linux
```bash
sudo apt-get update && sudo apt-get install -y crystal libssl-dev libsqlite3-dev
```
- macos
```bash
brew tap amberframework/micrate
brew install micrate
```
### Install Shards and Run
```bash
shards run bit
```
- Generate the `X-Api-Key`
```bash
shards run cli -- --create-user=Admin
```
- Run tests
```bash
ENV=test crystal spec
```
## Benchmark
Conducted on a MacBook Air M2 with 16GB RAM.
```
$ ./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
```
+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.4.1
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
+79 -28
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" => "http://idonthavespotify.donado.co"}
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()
@@ -51,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
@@ -59,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)
@@ -70,34 +94,29 @@ 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://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)
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
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
@@ -114,14 +133,14 @@ 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])
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|
@@ -133,7 +152,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])
@@ -143,7 +162,39 @@ 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.body.should eq(expected)
end
end
describe "Get" do
it "should return the specified link with click details" do
link = "https://bing.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" => "Resource 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 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
@@ -151,18 +202,18 @@ 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},
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
@@ -176,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
@@ -189,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
@@ -197,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)
@@ -211,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
@@ -219,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
+25 -6
View File
@@ -1,11 +1,21 @@
require "uuid" require "uuid"
require "file_utils"
require "spec-kemal" require "spec-kemal"
require "micrate" require "micrate"
require "../url-shortener" require "dotenv"
Dotenv.load ".env.#{ENV["ENV"]}"
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,20 +40,28 @@ 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 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)