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