Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30fb539289 | |||
| 66fd6db3c2 | |||
| 93a91cd76e | |||
| 1d1444234b | |||
| 49ac63210e | |||
| 702491cb39 | |||
| d55dbe0471 | |||
| 55969b03b5 | |||
| 70a036e158 | |||
| 6ade7d295b | |||
| e6ae133449 | |||
| d3706a8778 | |||
| a271e7c35d | |||
| a46a50b429 | |||
| dc8c359bfc | |||
| dfb6b10caf | |||
| 3fa30b3a32 | |||
| 80ed6033d1 | |||
| 4640522d5d | |||
| 848232cc11 | |||
| 98dedc4494 | |||
| e6f64ea026 | |||
| ea71d3825e | |||
| afa9b33568 | |||
| a93189411b | |||
| 98f103f5cf | |||
| 6fc48dae83 | |||
| d039add340 | |||
| 0214d6f46d | |||
| 37e14ec2f8 |
@@ -1,4 +1,4 @@
|
|||||||
name: Publish Docker image
|
name: Publish Docker images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -8,21 +8,20 @@ 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
|
||||||
|
|
||||||
@@ -32,34 +31,42 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version from shard.yml
|
- name: Extract version
|
||||||
id: extract_version
|
id: 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 "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "RELEASE_TAG=$VERSION" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set tags
|
- name: Build and push platform image
|
||||||
id: set_tags
|
uses: docker/build-push-action@v5
|
||||||
run: |
|
env:
|
||||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
CRYSTAL_WORKERS: ${{ matrix.platform == 'linux/amd64' && 4 || 2 }}
|
||||||
echo "TAGS=latest,${{ env.RELEASE_TAG }}" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "TAGS=latest" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build and push image
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@v5.0.0
|
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
tags: |
|
||||||
tags: sjdonado/bit:${{ env.TAGS }}
|
sjdonado/bit:${{ github.event_name == 'release' && steps.version.outputs.version || 'latest' }}-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
build-args: |
|
||||||
|
TARGETARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Attest
|
create-manifest:
|
||||||
uses: actions/attest-build-provenance@v1
|
name: Create Manifest
|
||||||
id: attest
|
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/bit
|
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}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/docs/
|
|
||||||
/lib/
|
/lib/
|
||||||
/bin/
|
/bin/
|
||||||
/.shards/
|
/.shards/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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).
|
||||||
+25
-16
@@ -1,32 +1,41 @@
|
|||||||
FROM alpine:edge AS build
|
FROM debian:bookworm-slim AS build
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
ENV ENV=production
|
ENV ENV=production
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN apk update && apk add --no-cache \
|
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 \
|
crystal \
|
||||||
shards \
|
libssl-dev \
|
||||||
yaml-dev \
|
libyaml-dev \
|
||||||
sqlite-dev \
|
libsqlite3-dev \
|
||||||
openssl-dev
|
libevent-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN shards install
|
RUN shards install --production
|
||||||
RUN shards build --release --no-debug
|
RUN shards build --release --no-debug --progress --stats
|
||||||
|
|
||||||
FROM alpine:edge AS runtime
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
ENV ENV=production
|
ENV ENV=production
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y \
|
||||||
gc \
|
libssl3 \
|
||||||
pcre2 \
|
libyaml-0-2 \
|
||||||
libevent \
|
libsqlite3-0 \
|
||||||
yaml \
|
libevent-2.1-7 \
|
||||||
sqlite-libs \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
openssl
|
|
||||||
|
|
||||||
RUN mkdir -p sqlite
|
RUN mkdir -p sqlite
|
||||||
|
|
||||||
|
|||||||
@@ -1,283 +1,28 @@
|
|||||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
[](https://hub.docker.com/r/sjdonado/bit)
|
||||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
[](https://hub.docker.com/r/sjdonado/bit)
|
||||||
[](https://hub.docker.com/repository/docker/sjdonado/bit)
|
[](https://hub.docker.com/r/sjdonado/bit)
|
||||||
|
|
||||||
# Benchmark
|
# Bit URL Shortener
|
||||||
|
|
||||||
```shell
|
Lightweight URL shortener service with minimal resource requirements. Average memory consumption is **20MB RAM** with container disk space under **50MB**.
|
||||||
$ ./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
|
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).
|
||||||
|
|
||||||
## Run via docker-compose
|
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
|
||||||
|
|
||||||
# Generate an api key
|
|
||||||
docker-compose exec -it app cli --create-user=Admin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run via docker cli
|
## Minimum Requirements
|
||||||
|
- 50MB disk space
|
||||||
|
- 50MB RAM (20MB avg usage)
|
||||||
|
- x86_64 or ARM64 architecture
|
||||||
|
|
||||||
```bash
|
## Documentation
|
||||||
docker run \
|
- [API Reference](docs/API.md)
|
||||||
--name bit \
|
- [Advanced Setup](docs/SETUP.md)
|
||||||
-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 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 cli --create-user=Admin
|
|
||||||
```
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
1. **Ping the API**
|
|
||||||
|
|
||||||
- Endpoint: `GET /api/ping`
|
|
||||||
- Payload: None
|
|
||||||
- Response Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "pong"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Retrieve a link by its slug**
|
|
||||||
|
|
||||||
- Endpoint: `GET /:slug`
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Payload: None
|
|
||||||
- Response Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
|
||||||
"origin": "https://monocuco.donado.co",
|
|
||||||
"clicks": [
|
|
||||||
{
|
|
||||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
|
||||||
"language": "en-US",
|
|
||||||
"browser": "Firefox",
|
|
||||||
"os": "Mac OS X",
|
|
||||||
"source": "Unknown",
|
|
||||||
"created_at": "2024-07-12T19:25:22Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Retrieve all links**
|
|
||||||
|
|
||||||
- Endpoint: `GET /api/links`
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Payload: None
|
|
||||||
- Response Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
|
||||||
"origin": "https://monocuco.donado.co",
|
|
||||||
"clicks": [
|
|
||||||
{
|
|
||||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
|
||||||
"language": "en-US",
|
|
||||||
"browser": "Firefox",
|
|
||||||
"os": "Mac OS X",
|
|
||||||
"source": "Unknown",
|
|
||||||
"created_at": "2024-07-12T19:25:22Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Retrieve a link by its ID**
|
|
||||||
|
|
||||||
- Endpoint: `GET /api/links/:id`
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Payload: None
|
|
||||||
- Response Example
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
|
||||||
"origin": "https://monocuco.donado.co",
|
|
||||||
"clicks": [
|
|
||||||
{
|
|
||||||
"id": "730e2202-58f9-478c-a24c-f1c561df6716",
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
|
||||||
"language": "en-US",
|
|
||||||
"browser": "Firefox",
|
|
||||||
"os": "Mac OS X",
|
|
||||||
"source": "Unknown",
|
|
||||||
"created_at": "2024-07-12T19:25:22Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Create a new link**
|
|
||||||
|
|
||||||
- Endpoint\*\*: `POST /api/links`
|
|
||||||
- Payload:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "https://example.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Response Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
|
||||||
"origin": "https://monocuco.donado.co/test",
|
|
||||||
"clicks": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Update an existing link by its ID**
|
|
||||||
|
|
||||||
- Endpoint: `PUT /api/links/:id`
|
|
||||||
- Payload:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "https://newexample.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Response Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "84f0c7a4-8c4e-4665-b676-cb9c5e40f1db",
|
|
||||||
"refer": "http://localhost:4000/3wP4BQ",
|
|
||||||
"origin": "https://newexample.com",
|
|
||||||
"clicks": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Delete a link by its ID**
|
|
||||||
|
|
||||||
- Endpoint: `DELETE /api/links/:id`
|
|
||||||
- Payload: None
|
|
||||||
- Headers: `X-Api-Key`
|
|
||||||
- Response Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Link deleted"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```
|
|
||||||
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 bit
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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/bit/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
|
|
||||||
|
|||||||
@@ -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
-11
@@ -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,16 +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)
|
||||||
attempts = 0
|
|
||||||
loop do
|
|
||||||
slug = Random::Secure.urlsafe_base64(attempts >= 2 ? 6 : 5).gsub(/[^a-zA-Z0-9]/, "")
|
|
||||||
unless Database.get_by(Link, slug: slug)
|
|
||||||
link.slug = slug
|
|
||||||
break
|
|
||||||
end
|
|
||||||
attempts += 1
|
|
||||||
end
|
|
||||||
|
|
||||||
changeset = Database.insert(link)
|
changeset = Database.insert(link)
|
||||||
if !changeset.valid?
|
if !changeset.valid?
|
||||||
@@ -126,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)
|
||||||
@@ -138,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?
|
||||||
|
|||||||
+1
-1
@@ -17,6 +17,6 @@ module App::Models
|
|||||||
unique_constraint :slug
|
unique_constraint :slug
|
||||||
|
|
||||||
validate_required [:slug, :url]
|
validate_required [:slug, :url]
|
||||||
validate_format :url, /\Ahttps?:\/\/(?:[\w.-]+)(?::\d+)?(?:[\/?#]\S*)?\z/i
|
validate_format :url, /\A(?:(https?:\/\/)?(?:[\w-]+\.)+[a-z]{2,})(?::\d+)?(?:[\/?#]\S*)?\z/i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ module App
|
|||||||
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, Origin, X-Api-Key"
|
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
|
||||||
env.response.headers.delete("X-Powered-By")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
after_all do |env|
|
after_all do |env|
|
||||||
|
|||||||
+20
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+129
-121
@@ -1,148 +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
|
|
||||||
|
|
||||||
# Create a new user and capture the API key
|
total_cpu=$(echo "$total_cpu + $cpu" | bc)
|
||||||
output=$(docker-compose exec -T app cli --create-user=Admin)
|
total_mem=$(echo "$total_mem + $mem" | bc)
|
||||||
api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}')
|
((count++))
|
||||||
echo "Captured API Key: $api_key"
|
fi
|
||||||
|
done < resource_usage.csv
|
||||||
|
|
||||||
echo "Waiting for database to be ready..."
|
avg_cpu=0.00
|
||||||
sleep 5
|
avg_mem=0.00
|
||||||
|
|
||||||
measure
|
if (( count > 0 )); then
|
||||||
|
avg_cpu=$(echo "scale=2; $total_cpu / $count" | bc)
|
||||||
|
avg_mem=$(echo "scale=2; $total_mem / $count" | bc)
|
||||||
|
fi
|
||||||
|
|
||||||
# Clean up
|
echo "**** Results ****"
|
||||||
docker-compose down
|
echo "Average CPU Usage: $avg_cpu%"
|
||||||
|
echo "Average Memory Usage: $avg_mem MiB"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
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
|
||||||
|
|||||||
@@ -5,10 +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)
|
||||||
|
|
||||||
|
App::Services::Cli.setup_admin_user
|
||||||
|
|
||||||
Kemal.run
|
Kemal.run
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
container_name: bit
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
ENV: production
|
ENV: production
|
||||||
|
ADMIN_NAME: 'Tester'
|
||||||
|
ADMIN_API_KEY: '0p+mDvbpZGLPGVCXnV+EDduR9Blkv27Dhq9XSzSbdQY='
|
||||||
ports:
|
ports:
|
||||||
- 4000:4000
|
- 4000:4000
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+152
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: bit
|
name: bit
|
||||||
version: 1.2.0
|
version: 1.4.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Juan Rodriguez <sjdonado@icloud.com>
|
- Juan Rodriguez <sjdonado@icloud.com>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user