From 4c2e4b9116a293be4110d9ceaf7d112f95768f07 Mon Sep 17 00:00:00 2001 From: unurgunite Date: Sun, 16 Nov 2025 17:16:21 +0300 Subject: [PATCH 1/6] Added start endpoints --- .env.sample | 4 + .gitignore | 1 + Dockerfile | 15 ++ compose.yml | 32 ++++ shard.lock | 30 ++- shard.yml | 8 + src/stem.cr | 536 +++++++++++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 .env.sample create mode 100644 Dockerfile create mode 100644 compose.yml diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..f0d13ce --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/florex +PORT=3000 +CORS_ORIGIN=http://localhost:5173 +KEMAL_ENV=development diff --git a/.gitignore b/.gitignore index 942d66d..cb97cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.shards/ *.dwarf .idea/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9a0ee6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM crystallang/crystal:1.18.2-alpine AS build +WORKDIR /app +RUN apk add --no-cache build-base openssl-dev pcre2-dev yaml-dev zlib-dev +COPY shard.yml shard.lock ./ +RUN shards install --production +COPY . . +RUN shards build --release --no-debug stem + +FROM alpine:3.19 +RUN apk add --no-cache openssl pcre2 yaml zlib libgcc tzdata postgresql-client +WORKDIR /app +COPY --from=build /app/bin/stem /usr/local/bin/stem +ENV PORT=3000 +EXPOSE 3000 +CMD ["stem"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c22cc3f --- /dev/null +++ b/compose.yml @@ -0,0 +1,32 @@ +services: + db: + image: postgres:18.1-alpine + environment: + POSTGRES_USER: florex + POSTGRES_PASSWORD: florex + POSTGRES_DB: florex + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U florex -d florex"] + interval: 5s + timeout: 3s + retries: 10 + ports: + - "5432:5432" + + stem: + build: . + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://florex:florex@db:5432/florex + PORT: "3000" + CORS_ORIGIN: http://localhost:5173 + KEMAL_ENV: development + ports: + - "3000:3000" + +volumes: + pgdata: diff --git a/shard.lock b/shard.lock index 4f3e149..93e4423 100644 --- a/shard.lock +++ b/shard.lock @@ -1,2 +1,30 @@ version: 2.0 -shards: {} +shards: + backtracer: + git: https://github.com/sija/backtracer.cr.git + version: 1.2.4 + + db: + git: https://github.com/crystal-lang/crystal-db.git + version: 0.13.1 + + dotenv: + git: https://github.com/drum445/dotenv.git + version: 0.5.0 + + exception_page: + git: https://github.com/crystal-loot/exception_page.git + version: 0.5.0 + + kemal: + git: https://github.com/kemalcr/kemal.git + version: 1.8.0 + + pg: + git: https://github.com/will/crystal-pg.git + version: 0.29.0 + + radix: + git: https://github.com/luislavena/radix.git + version: 0.4.1 + diff --git a/shard.yml b/shard.yml index a69d928..a9a9ad4 100644 --- a/shard.yml +++ b/shard.yml @@ -8,6 +8,14 @@ targets: stem: main: src/stem.cr +dependencies: + kemal: + github: kemalcr/kemal + pg: + github: will/crystal-pg + dotenv: + github: drum445/dotenv + crystal: '>= 1.18.2' license: MIT diff --git a/src/stem.cr b/src/stem.cr index d21db5f..733ae98 100644 --- a/src/stem.cr +++ b/src/stem.cr @@ -1,6 +1,538 @@ -# TODO: Write documentation for `Stem` +# src/stem.cr +require "kemal" +require "pg" +require "json" +require "uri" +require "dotenv" + +# ============ +# DB bootstrap +# ============ module Stem VERSION = "0.1.0" - # TODO: Put your code here + Dotenv.load + DEFAULT_DB_URL = "postgres://postgres:postgres@127.0.0.1:5432/florex" + + @@db : DB::Database? + + def self.db : DB::Database + @@db ||= DB.open(ENV["DATABASE_URL"]? || DEFAULT_DB_URL) + end + + def self.migrate! + db = self.db + + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS services ( + id BIGSERIAL PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + kind TEXT NOT NULL, + url TEXT, + status TEXT NOT NULL DEFAULT 'ok', + avg_response_ms INT NOT NULL DEFAULT 0, + last_check TIMESTAMPTZ, + check_interval_seconds INT NOT NULL DEFAULT 60, + timeout_seconds INT NOT NULL DEFAULT 30, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + SQL + + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS service_metrics ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + response_ms INT NOT NULL, + status TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_service_metrics_service_ts ON service_metrics(service_id, ts DESC); + SQL + + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS logs ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + message TEXT NOT NULL, + severity TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON logs(service_id, ts DESC); + CREATE INDEX IF NOT EXISTS idx_logs_severity_ts ON logs(severity, ts DESC); + SQL + + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS alerts ( + id BIGSERIAL PRIMARY KEY, + service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + severity TEXT NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_alerts_open ON alerts(service_id) WHERE resolved_at IS NULL; + SQL + + db.exec <<-SQL + CREATE TABLE IF NOT EXISTS alert_channels ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_alert_channels_kind ON alert_channels(kind); + SQL + end + + def self.seed! + db = self.db + + # Services + count = db.scalar("SELECT COUNT(*) FROM services").as(Int64) + if count == 0 + db.exec <<-SQL + INSERT INTO services (name, kind, url, status, avg_response_ms, last_check, check_interval_seconds, timeout_seconds) + VALUES + ('rails-api', 'Rails', 'https://api.example.com', 'ok', 145, NOW() - INTERVAL '2 minutes', 60, 30), + ('node-worker', 'Node.js', 'https://worker.example.com', 'ok', 89, NOW() - INTERVAL '1 minutes', 60, 30), + ('auth-service', 'Rails', 'https://auth.example.com', 'warning', 312, NOW() - INTERVAL '3 minutes', 60, 30), + ('websocket-server', 'Node.js', 'wss://ws.example.com', 'ok', 52, NOW() - INTERVAL '1 minutes', 30, 10), + ('payment-gateway', 'Rails', 'https://pay.example.com', 'ok', 198, NOW() - INTERVAL '2 minutes', 60, 30), + ('email-processor', 'Node.js', 'https://mail.example.com', 'error', 0, NOW() - INTERVAL '5 minutes', 60, 30) + SQL + + # Seed some metrics (last 12 points-ish) + db.query "SELECT id, name FROM services" do |rs| + while rs.move_next + sid = rs.read(Int64) + name = rs.read(String) + # simple synthetic metrics + 12.times do |i| + delay_ms = case name + when "email-processor" then i < 5 ? 40 + 5*i : 0 + when "auth-service" then 220 + (i*15) + when "rails-api" then 120 + (i%5)*5 + when "node-worker" then 80 + (i%5)*3 + when "websocket-server" then 50 + (i%5)*2 + when "payment-gateway" then 160 + (i%5)*8 + else 100 + end + st = delay_ms == 0 ? "error" : (delay_ms > 250 ? "warning" : "ok") + db.exec "INSERT INTO service_metrics (service_id, ts, response_ms, status) VALUES ($1, NOW() - ($2 || ' minutes')::interval, $3, $4)", + sid, (12 - i), delay_ms, st + end + end + end + + # Alerts + db.exec <<-SQL + INSERT INTO alerts (service_id, severity, message, created_at) + SELECT s.id, 'critical', 'Service is down - Connection timeout', NOW() + FROM services s WHERE s.name = 'email-processor'; + INSERT INTO alerts (service_id, severity, message, created_at) + SELECT s.id, 'warning', 'High response time detected (>300ms)', NOW() - INTERVAL '5 minutes' + FROM services s WHERE s.name = 'auth-service'; + INSERT INTO alerts (service_id, severity, message, created_at) + SELECT s.id, 'info', 'Service recovered', NOW() - INTERVAL '30 minutes' + FROM services s WHERE s.name = 'rails-api'; + SQL + + # Logs + db.exec <<-SQL + INSERT INTO logs (service_id, ts, message, severity) + SELECT s.id, NOW() - INTERVAL '0 minutes', 'Connection timeout after 30 seconds', 'critical' + FROM services s WHERE s.name = 'email-processor'; + INSERT INTO logs (service_id, ts, message, severity) + SELECT s.id, NOW() - INTERVAL '1 minutes', 'Retry attempt 3/3 failed', 'error' + FROM services s WHERE s.name = 'email-processor'; + INSERT INTO logs (service_id, ts, message, severity) + SELECT s.id, NOW() - INTERVAL '5 minutes', 'Response time 312ms exceeds threshold', 'warning' + FROM services s WHERE s.name = 'auth-service'; + INSERT INTO logs (service_id, ts, message, severity) + SELECT s.id, NOW() - INTERVAL '8 minutes', 'Health check passed', 'info' + FROM services s WHERE s.name = 'rails-api'; + SQL + + # Alert channels + db.exec <<-SQL + INSERT INTO alert_channels (kind, enabled, config) + VALUES + ('email', TRUE, '{"recipients": ["admin@example.com"]}'), + ('slack', TRUE, '{"webhook_url": "https://hooks.slack.com/services/..."}'), + ('telegram', FALSE, '{"bot_token": "", "chat_id": ""}'), + ('mattermost', FALSE, '{"webhook_url": ""}') + ON CONFLICT (kind) DO NOTHING; + SQL + end + end +end + +# ================== +# DTOs (camelCased) +# ================== +struct ServiceDTO + include JSON::Serializable + + property id : Int64 + property name : String + @[JSON::Field(key: "type")] + property kind : String + property status : String + @[JSON::Field(key: "responseTime")] + property response_time : Int32 + @[JSON::Field(key: "lastCheck")] + property last_check_human : String + property sparkline : Array(Int32) + + def initialize( + @id : Int64, + @name : String, + @kind : String, + @status : String, + @response_time : Int32, + @last_check_human : String, + @sparkline : Array(Int32) + ); end +end + +struct LogDTO + include JSON::Serializable + + property id : Int64 + property timestamp : String + property service : String + property message : String + property severity : String + + def initialize( + @id : Int64, + @timestamp : String, + @service : String, + @message : String, + @severity : String + ); end +end + +struct AlertDTO + include JSON::Serializable + + property id : Int64 + property service : String + property message : String + property severity : String + property timestamp : String + + def initialize( + @id : Int64, + @service : String, + @message : String, + @severity : String, + @timestamp : String + ); end +end + +struct AlertChannelDTO + include JSON::Serializable + + property id : Int64 + property name : String + property enabled : Bool + property config : JSON::Any + @[JSON::Field(key: "icon")] + property icon_path : String + + def initialize( + @id : Int64, + @name : String, + @enabled : Bool, + @config : JSON::Any, + @icon_path : String + ); end +end + +# =========== +# Utilities +# =========== +def time_ago(ts : Time?) : String + return "—" unless ts + diff = Time.utc - ts + mins = (diff.total_minutes).to_i + return "#{mins} min ago" if mins < 60 + hours = (diff.total_hours).to_i + return "#{hours} hour ago" if hours == 1 + return "#{hours} hours ago" if hours < 24 + days = (diff.total_days).to_i + days == 1 ? "1 day ago" : "#{days} days ago" +end + +def normalize_spark(values : Array(Int32)) : Array(Int32) + max = values.max? || 1 + return values.map { |v| 0 } if max == 0 + values.map { |v| ((v.to_f / max) * 100.0).round.to_i } +end + +def icon_for_channel(kind : String) : String + case kind + when "email" + "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" + when "slack" + "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" + when "telegram" + "M12 19l9 2-9-18-9 18 9-2zm0 0v-8" + when "mattermost" + "M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" + else + "" + end +end + +# ================== +# CORS + JSON helper +# ================== +before_all do |env| + env.response.headers["Access-Control-Allow-Origin"] = ENV["CORS_ORIGIN"]? || "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" +end + +options "/*" do |env| + env.response.status_code = 204 + "" end + +def json(env, payload, status = 200) + env.response.status_code = status + env.response.content_type = "application/json" + payload.to_json +end + +# ========= +# Routes +# ========= +get "/api/health" { |env| json(env, {ok: true, version: Stem::VERSION}) } + +# Summary for hero cards +get "/api/status" do |env| + db = Stem.db + total = db.scalar("SELECT COUNT(*) FROM services").as(Int64) + healthy = db.scalar("SELECT COUNT(*) FROM services WHERE status = 'ok'").as(Int64) + active_alerts = db.scalar("SELECT COUNT(*) FROM alerts WHERE resolved_at IS NULL AND severity IN ('critical','error')").as(Int64) rescue 0_i64 + uptime = 99.8 # placeholder – compute properly later from metrics + + json(env, { + services: total, + healthy: healthy, + activeAlerts: active_alerts, + uptimePercent: uptime, + }) +end + +# List services (with sparkline) +get "/api/services" do |env| + db = Stem.db + services = [] of ServiceDTO + + db.query "SELECT id, name, kind, status, avg_response_ms, last_check FROM services ORDER BY name" do |rs| + while rs.move_next + id = rs.read(Int64) + name = rs.read(String) + kind = rs.read(String) + status = rs.read(String) + avg_rt = rs.read(Int32) + last_check = rs.read(Time?) + # load last 12 response_ms for sparkline + points = [] of Int32 + db.query "SELECT response_ms FROM service_metrics WHERE service_id = $1 ORDER BY ts DESC LIMIT 12", id do |rs2| + while rs2.move_next + points << rs2.read(Int32) + end + end + points = points.reverse + services << ServiceDTO.new( + id: id, + name: name, + kind: kind, + status: status, + response_time: avg_rt, + last_check_human: time_ago(last_check), + sparkline: normalize_spark(points) + ) + end + end + + json(env, services) +end + +# Service detail + last 24h points +get "/api/services/:id" do |env| + db = Stem.db + id = env.params.url["id"].to_i64 + + row = db.query_one? "SELECT id, name, kind, status, avg_response_ms, last_check FROM services WHERE id = $1", id, as: { + Int64, String, String, String, Int32, Time? + } + + unless row + env.response.status_code = 404 + next json(env, {error: "Service not found"}) + end + + sid, name, kind, status, avg_rt, last_check = row + + points = [] of Int32 + db.query "SELECT response_ms FROM service_metrics WHERE service_id = $1 AND ts >= NOW() - INTERVAL '24 hours' ORDER BY ts ASC", id do |rs2| + while rs2.move_next + points << rs2.read(Int32) + end + end + + dto = ServiceDTO.new( + id: sid, + name: name, + kind: kind, + status: status, + response_time: avg_rt, + last_check_human: time_ago(last_check), + sparkline: normalize_spark(points.last(12)) # keep last 12 for UI + ) + + json(env, dto) +end + +# Create a service (simple) +post "/api/services" do |env| + body = env.request.body.try &.gets_to_end + unless body + next json(env, {error: "Empty body"}, 400) + end + data = JSON.parse(body) + name = data["name"].as_s + kind = data["type"]?.try(&.as_s) || "Generic" + url = data["url"]?.try(&.as_s) + check_interval = data["checkInterval"]?.try(&.as_i) || 60 + timeout = data["timeout"]?.try(&.as_i) || 30 + + db = Stem.db + db.exec "INSERT INTO services (name, kind, url, status, avg_response_ms, last_check, check_interval_seconds, timeout_seconds) VALUES ($1, $2, $3, 'ok', 0, NOW(), $4, $5)", + name, kind, url, check_interval, timeout + + json(env, {ok: true}) +end + +# Delete service +delete "/api/services/:id" do |env| + id = env.params.url["id"].to_i64 + db = Stem.db + db.exec "DELETE FROM services WHERE id = $1", id + json(env, {ok: true}) +end + +# Logs list + filtering: /api/logs?search=&severity=&service=&limit=100 +# Ingest a log (MVP) +post "/api/logs" do |env| + body = env.request.body.try &.gets_to_end + data = body ? JSON.parse(body) : JSON.parse("{}") + service = data["service"]?.try(&.as_s) + message = data["message"]?.try(&.as_s) || "" + severity = data["severity"]?.try(&.as_s) || "info" + + unless service + next json(env, {error: "service is required"}, 400) + end + + db = Stem.db + + if sid = db.query_one? "SELECT id FROM services WHERE name = $1", service, as: Int64 + db.exec "INSERT INTO logs (service_id, message, severity) VALUES ($1, $2, $3)", + sid, message, severity + json(env, {ok: true}) + else + json(env, {error: "Unknown service"}, 404) + end +end + +# Alerts list (recent, unresolved first) +get "/api/alerts" do |env| + db = Stem.db + alerts = [] of AlertDTO + db.query <<-SQL do |rs| + SELECT a.id, s.name, a.message, a.severity, a.created_at + FROM alerts a + JOIN services s ON s.id = a.service_id + ORDER BY (a.resolved_at IS NULL) DESC, a.created_at DESC + LIMIT 200 + SQL + while rs.move_next + id = rs.read(Int64) + svc = rs.read(String) + msg = rs.read(String) + sev = rs.read(String) + ts = rs.read(Time) + alerts << AlertDTO.new( + id: id, + service: svc, + message: msg, + severity: sev, + timestamp: ts.to_s("%Y-%m-%d %H:%M:%S") + ) + end + end + + json(env, alerts) +end + +# Alert channels config (maps to your UI) +get "/api/alerts/channels" do |env| + db = Stem.db + channels = [] of AlertChannelDTO + db.query "SELECT id, kind, enabled, config FROM alert_channels ORDER BY id" do |rs| + while rs.move_next + id = rs.read(Int64) + kind = rs.read(String) + enabled = rs.read(Bool) + config = JSON.parse(rs.read(String)) + channels << AlertChannelDTO.new( + id: id, + name: kind.capitalize, + enabled: enabled, + config: config, + icon_path: icon_for_channel(kind) + ) + end + end + json(env, channels) +end + +# Update a channel +put "/api/alerts/channels/:id" do |env| + id = env.params.url["id"].to_i64 + body = env.request.body.try &.gets_to_end + unless body + next json(env, {error: "Empty body"}, 400) + end + data = JSON.parse(body) + enabled = data["enabled"]?.try(&.as_bool) + config = data["config"]? || JSON.parse("{}") + + db = Stem.db + if enabled.nil? + db.exec "UPDATE alert_channels SET config = $1::jsonb, updated_at = NOW() WHERE id = $2", config.to_json, id + else + db.exec "UPDATE alert_channels SET enabled = $1, config = $2::jsonb, updated_at = NOW() WHERE id = $3", enabled.as(Bool), config.to_json, id + end + json(env, {ok: true}) +end + +# Start server +# Kemal.config.logger = Kemal::BaseLogHandler#.new(STDOUT) +post "/__admin/seed" { |env| Stem.seed!; json(env, {ok: true}) } + +Stem.migrate! +Stem.seed! +Kemal.config.host = "0.0.0.0" +Kemal.config.port = (ENV["PORT"]? || "3000").to_i + +Kemal.run(ENV["PORT"]?.try(&.to_i) || 3000) From 49dd6c251dc514c313ebdb563632fa21302f3358 Mon Sep 17 00:00:00 2001 From: unurgunite Date: Thu, 27 Nov 2025 12:44:22 +0300 Subject: [PATCH 2/6] Moved from Kemal to Lucky framework --- .crystal-version | 1 + .env.sample | 23 +- .github/workflows/ci.yml | 88 +++ .gitignore | 6 +- Dockerfile | 15 - Procfile | 2 + Procfile.dev | 2 + README.md | 32 +- compose.yml | 46 +- config/application.cr | 24 + config/authentic.cr | 11 + config/colors.cr | 4 + config/cookies.cr | 25 + config/database.cr | 27 + config/email.cr | 26 + config/env.cr | 33 ++ config/error_handler.cr | 3 + config/log.cr | 50 ++ config/route_helper.cr | 10 + config/server.cr | 65 +++ config/watch.yml | 3 + db/migrations/.keep | 0 db/migrations/00000000000001_create_users.cr | 17 + docker/dev_entrypoint.sh | 46 ++ docker/development.dockerfile | 15 + docker/wait-for-it.sh | 189 ++++++ public/favicon.ico | 0 script/helpers/function_helpers.cr | 32 ++ script/setup.cr | 27 + script/system_check.cr | 17 + shard.lock | 90 ++- shard.yml | 48 +- spec/requests/api/me/show_spec.cr | 17 + spec/requests/api/sign_ins/create_spec.cr | 33 ++ spec/requests/api/sign_ups/create_spec.cr | 34 ++ spec/setup/clean_database.cr | 3 + spec/setup/reset_emails.cr | 3 + spec/setup/setup_database.cr | 2 + spec/setup/start_app_server.cr | 9 + spec/spec_helper.cr | 19 +- spec/stem_spec.cr | 9 - spec/support/.keep | 0 spec/support/api_client.cr | 12 + spec/support/factories/.keep | 0 spec/support/factories/user_factory.cr | 6 + src/actions/api/me/show.cr | 5 + src/actions/api/sign_ins/create.cr | 13 + src/actions/api/sign_ups/create.cr | 8 + src/actions/api_action.cr | 17 + src/actions/errors/show.cr | 42 ++ src/actions/home/index.cr | 7 + src/actions/mixins/.keep | 0 src/actions/mixins/api/auth/helpers.cr | 28 + .../mixins/api/auth/require_auth_token.cr | 34 ++ .../api/auth/skip_require_auth_token.cr | 10 + src/app.cr | 20 + src/app_database.cr | 2 + src/app_server.cr | 28 + src/emails/base_email.cr | 15 + src/models/base_model.cr | 5 + src/models/mixins/.keep | 0 src/models/user.cr | 13 + src/models/user_token.cr | 30 + src/operations/.keep | 0 src/operations/mixins/.keep | 0 src/operations/mixins/password_validations.cr | 12 + src/operations/mixins/user_from_email.cr | 7 + src/operations/request_password_reset.cr | 25 + src/operations/reset_password.cr | 11 + src/operations/sign_in_user.cr | 40 ++ src/operations/sign_up_user.cr | 14 + src/queries/.keep | 0 src/queries/mixins/.keep | 0 src/queries/user_query.cr | 2 + src/serializers/.keep | 0 src/serializers/base_serializer.cr | 9 + src/serializers/error_serializer.cr | 14 + src/serializers/user_serializer.cr | 8 + src/shards.cr | 10 + src/start_server.cr | 17 + src/stem.cr | 538 ------------------ src/stem_temp.cr | 6 + tasks.cr | 25 + tasks/.keep | 0 tasks/db/seed/required_data.cr | 30 + tasks/db/seed/sample_data.cr | 30 + 86 files changed, 1568 insertions(+), 631 deletions(-) create mode 100644 .crystal-version create mode 100644 .github/workflows/ci.yml delete mode 100644 Dockerfile create mode 100644 Procfile create mode 100644 Procfile.dev create mode 100644 config/application.cr create mode 100644 config/authentic.cr create mode 100644 config/colors.cr create mode 100644 config/cookies.cr create mode 100644 config/database.cr create mode 100644 config/email.cr create mode 100644 config/env.cr create mode 100644 config/error_handler.cr create mode 100644 config/log.cr create mode 100644 config/route_helper.cr create mode 100644 config/server.cr create mode 100644 config/watch.yml create mode 100644 db/migrations/.keep create mode 100644 db/migrations/00000000000001_create_users.cr create mode 100755 docker/dev_entrypoint.sh create mode 100644 docker/development.dockerfile create mode 100755 docker/wait-for-it.sh create mode 100644 public/favicon.ico create mode 100644 script/helpers/function_helpers.cr create mode 100644 script/setup.cr create mode 100644 script/system_check.cr create mode 100644 spec/requests/api/me/show_spec.cr create mode 100644 spec/requests/api/sign_ins/create_spec.cr create mode 100644 spec/requests/api/sign_ups/create_spec.cr create mode 100644 spec/setup/clean_database.cr create mode 100644 spec/setup/reset_emails.cr create mode 100644 spec/setup/setup_database.cr create mode 100644 spec/setup/start_app_server.cr delete mode 100644 spec/stem_spec.cr create mode 100644 spec/support/.keep create mode 100644 spec/support/api_client.cr create mode 100644 spec/support/factories/.keep create mode 100644 spec/support/factories/user_factory.cr create mode 100644 src/actions/api/me/show.cr create mode 100644 src/actions/api/sign_ins/create.cr create mode 100644 src/actions/api/sign_ups/create.cr create mode 100644 src/actions/api_action.cr create mode 100644 src/actions/errors/show.cr create mode 100644 src/actions/home/index.cr create mode 100644 src/actions/mixins/.keep create mode 100644 src/actions/mixins/api/auth/helpers.cr create mode 100644 src/actions/mixins/api/auth/require_auth_token.cr create mode 100644 src/actions/mixins/api/auth/skip_require_auth_token.cr create mode 100644 src/app.cr create mode 100644 src/app_database.cr create mode 100644 src/app_server.cr create mode 100644 src/emails/base_email.cr create mode 100644 src/models/base_model.cr create mode 100644 src/models/mixins/.keep create mode 100644 src/models/user.cr create mode 100644 src/models/user_token.cr create mode 100644 src/operations/.keep create mode 100644 src/operations/mixins/.keep create mode 100644 src/operations/mixins/password_validations.cr create mode 100644 src/operations/mixins/user_from_email.cr create mode 100644 src/operations/request_password_reset.cr create mode 100644 src/operations/reset_password.cr create mode 100644 src/operations/sign_in_user.cr create mode 100644 src/operations/sign_up_user.cr create mode 100644 src/queries/.keep create mode 100644 src/queries/mixins/.keep create mode 100644 src/queries/user_query.cr create mode 100644 src/serializers/.keep create mode 100644 src/serializers/base_serializer.cr create mode 100644 src/serializers/error_serializer.cr create mode 100644 src/serializers/user_serializer.cr create mode 100644 src/shards.cr create mode 100644 src/start_server.cr delete mode 100644 src/stem.cr create mode 100644 src/stem_temp.cr create mode 100644 tasks.cr create mode 100644 tasks/.keep create mode 100644 tasks/db/seed/required_data.cr create mode 100644 tasks/db/seed/sample_data.cr diff --git a/.crystal-version b/.crystal-version new file mode 100644 index 0000000..b57fc72 --- /dev/null +++ b/.crystal-version @@ -0,0 +1 @@ +1.18.2 diff --git a/.env.sample b/.env.sample index f0d13ce..391721a 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,21 @@ -DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/florex +# Lucky env +LUCKY_ENV=development PORT=3000 -CORS_ORIGIN=http://localhost:5173 -KEMAL_ENV=development + +# DB connection for local dev +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD=postgres +DB_NAME=florex + +# Optional: if you prefer a full URL: +# DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/florex + +# For Lucky encryption/session +SECRET_KEY_BASE= + +# pgsql +POSTGRES_USER=lucky +POSTGRES_PASSWORD=password +POSTGRES_DB=lucky diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1dfbfc6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: stem_temp CI + +on: + push: + branches: "*" + pull_request: + branches: "*" + +jobs: + check-format: + strategy: + fail-fast: false + matrix: + crystal_version: + - 1.18.2 + experimental: + - false + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + steps: + - uses: actions/checkout@v4 + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: ${{ matrix.crystal_version }} + - name: Format + run: crystal tool format --check + + specs: + strategy: + fail-fast: false + matrix: + crystal_version: + - 1.18.2 + experimental: + - false + runs-on: ubuntu-latest + env: + LUCKY_ENV: test + DB_HOST: localhost + continue-on-error: ${{ matrix.experimental }} + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: ${{ matrix.crystal_version }} + + - name: Set up Crystal cache + uses: actions/cache@v4 + id: crystal-cache + with: + path: | + ~/.cache/crystal + lib + key: ${{ runner.os }}-crystal-${{ hashFiles('**/shard.lock') }} + restore-keys: | + ${{ runner.os }}-crystal- + + - name: Install shards + if: steps.crystal-cache.outputs.cache-hit != 'true' + run: shards check || shards install + + - name: Build lucky_tasks + run: crystal build tasks.cr -o ./lucky_tasks + + - name: Prepare database + run: | + ./lucky_tasks db.create + ./lucky_tasks db.migrate + ./lucky_tasks db.seed.required_data + + - name: Run tests + run: crystal spec \ No newline at end of file diff --git a/.gitignore b/.gitignore index cb97cfd..b03bacb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,9 @@ /bin/ /.shards/ *.dwarf -.idea/ +start_server +*.dwarf +*.local.cr .env +/tmp +.idea diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e9a0ee6..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM crystallang/crystal:1.18.2-alpine AS build -WORKDIR /app -RUN apk add --no-cache build-base openssl-dev pcre2-dev yaml-dev zlib-dev -COPY shard.yml shard.lock ./ -RUN shards install --production -COPY . . -RUN shards build --release --no-debug stem - -FROM alpine:3.19 -RUN apk add --no-cache openssl pcre2 yaml zlib libgcc tzdata postgresql-client -WORKDIR /app -COPY --from=build /app/bin/stem /usr/local/bin/stem -ENV PORT=3000 -EXPOSE 3000 -CMD ["stem"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..e524d70 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bin/app +release: lucky db.migrate diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..dc39e8f --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +system_check: crystal script/system_check.cr +web: lucky watch diff --git a/README.md b/README.md index 1f56188..476001b 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,23 @@ -# stem +# stem_temp -TODO: Write a description here +This is a project written using [Lucky](https://luckyframework.org). Enjoy! -## Installation +### Setting up the project -TODO: Write installation instructions here +1. [Install required dependencies](https://luckyframework.org/guides/getting-started/installing#install-required-dependencies) +1. Update database settings in `config/database.cr` +1. Run `script/setup` +1. Run `lucky dev` to start the app -## Usage +### Using Docker for development -TODO: Write usage instructions here +1. [Install Docker](https://docs.docker.com/engine/install/) +1. Run `docker compose up` -## Development +The Docker container will boot all of the necessary components needed to run your Lucky application. +To configure the container, update the `docker-compose.yml` file, and the `docker/development.dockerfile` file. -TODO: Write development instructions here -## Contributing +### Learning Lucky -1. Fork it () -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request - -## Contributors - -- [your-name-here](https://github.com/your-github-user) - creator and maintainer +Lucky uses the [Crystal](https://crystal-lang.org) programming language. You can learn about Lucky from the [Lucky Guides](https://luckyframework.org/guides/getting-started/why-lucky). diff --git a/compose.yml b/compose.yml index c22cc3f..2b5d5b1 100644 --- a/compose.yml +++ b/compose.yml @@ -2,31 +2,39 @@ services: db: image: postgres:18.1-alpine environment: - POSTGRES_USER: florex - POSTGRES_PASSWORD: florex - POSTGRES_DB: florex + POSTGRES_USER: ${DB_USERNAME:-lucky} + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + POSTGRES_DB: ${DB_NAME:-lucky} volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U florex -d florex"] - interval: 5s - timeout: 3s - retries: 10 + - db:/var/lib/postgresql/data ports: - - "5432:5432" + - "${DB_PORT:-5432}:5432" - stem: - build: . + lucky: + env_file: + - .env + tty: true + platform: linux/arm64/v8 + stdin_open: true + build: + context: . + dockerfile: docker/development.dockerfile + command: [] + entrypoint: ["docker/dev_entrypoint.sh"] + volumes: + - .:/app + - shards_lib:/app/lib + - app_bin:/app/bin + - build_cache:/root/.cache depends_on: - db: - condition: service_healthy + - db environment: - DATABASE_URL: postgres://florex:florex@db:5432/florex - PORT: "3000" - CORS_ORIGIN: http://localhost:5173 - KEMAL_ENV: development + DB_HOST: db ports: - "3000:3000" volumes: - pgdata: + db: + shards_lib: + app_bin: + build_cache: diff --git a/config/application.cr b/config/application.cr new file mode 100644 index 0000000..c807149 --- /dev/null +++ b/config/application.cr @@ -0,0 +1,24 @@ +# This file may be used for custom Application configurations. +# It will be loaded before other config files. +# +# Read more on configuration: +# https://luckyframework.org/guides/getting-started/configuration#configuring-your-own-code + +# Use this code as an example: +# +# ``` +# module Application +# Habitat.create do +# setting support_email : String +# setting lock_with_basic_auth : Bool +# end +# end +# +# Application.configure do |settings| +# settings.support_email = "support@myapp.io" +# settings.lock_with_basic_auth = LuckyEnv.staging? +# end +# +# # In your application, call +# # `Application.settings.support_email` anywhere you need it. +# ``` diff --git a/config/authentic.cr b/config/authentic.cr new file mode 100644 index 0000000..b9efc31 --- /dev/null +++ b/config/authentic.cr @@ -0,0 +1,11 @@ +require "./server" + +Authentic.configure do |settings| + settings.secret_key = Lucky::Server.settings.secret_key_base + + unless LuckyEnv.production? + # This value can be between 4 and 31 + fastest_encryption_possible = 4 + settings.encryption_cost = fastest_encryption_possible + end +end diff --git a/config/colors.cr b/config/colors.cr new file mode 100644 index 0000000..761ae94 --- /dev/null +++ b/config/colors.cr @@ -0,0 +1,4 @@ +# This enables the color output when in development or test +# Check out the Colorize docs for more information +# https://crystal-lang.org/api/Colorize.html +Colorize.enabled = LuckyEnv.development? || LuckyEnv.test? diff --git a/config/cookies.cr b/config/cookies.cr new file mode 100644 index 0000000..9e47480 --- /dev/null +++ b/config/cookies.cr @@ -0,0 +1,25 @@ +require "./server" + +Lucky::Session.configure do |settings| + settings.key = "_stem_temp_session" +end + +Lucky::CookieJar.configure do |settings| + settings.on_set = ->(cookie : HTTP::Cookie) { + # If ForceSSLHandler is enabled, only send cookies over HTTPS + cookie.secure(Lucky::ForceSSLHandler.settings.enabled) + + # By default, don't allow reading cookies with JavaScript + cookie.http_only(true) + + # Restrict cookies to a first-party or same-site context + cookie.samesite(:lax) + + # Set all cookies to the root path by default + cookie.path("/") + + # You can set other defaults for cookies here. For example: + # + # cookie.expires(1.year.from_now).domain("mydomain.com") + } +end diff --git a/config/database.cr b/config/database.cr new file mode 100644 index 0000000..72dcd9d --- /dev/null +++ b/config/database.cr @@ -0,0 +1,27 @@ +database_name = ENV["DB_NAME"]? || "stem_#{LuckyEnv.environment}" + +AppDatabase.configure do |settings| + if LuckyEnv.production? + # In production, prefer a single DATABASE_URL + settings.credentials = Avram::Credentials.parse(ENV["DATABASE_URL"]) + else + # In dev/test: use DATABASE_URL if present, else DB_* pieces + settings.credentials = + Avram::Credentials.parse?(ENV["DATABASE_URL"]?) || + Avram::Credentials.new( + database: database_name, + hostname: ENV["DB_HOST"]? || "localhost", + port: ENV["DB_PORT"]?.try(&.to_i) || 5432, + username: ENV["DB_USERNAME"]? || "postgres", + password: ENV["DB_PASSWORD"]? || "postgres", + ) + end +end + +Avram.configure do |settings| + settings.database_to_migrate = AppDatabase + + # In production, allow lazy loading (N+1). + # In development and test, raise an error if you forget to preload associations + settings.lazy_load_enabled = LuckyEnv.production? +end diff --git a/config/email.cr b/config/email.cr new file mode 100644 index 0000000..7c87544 --- /dev/null +++ b/config/email.cr @@ -0,0 +1,26 @@ +require "carbon_sendgrid_adapter" + +BaseEmail.configure do |settings| + if LuckyEnv.production? + # If you don't need to send emails, set the adapter to DevAdapter instead: + # + # settings.adapter = Carbon::DevAdapter.new + # + # If you do need emails, get a key from SendGrid and set an ENV variable + send_grid_key = send_grid_key_from_env + settings.adapter = Carbon::SendGridAdapter.new(api_key: send_grid_key) + elsif LuckyEnv.development? + settings.adapter = Carbon::DevAdapter.new(print_emails: true) + else + settings.adapter = Carbon::DevAdapter.new + end +end + +private def send_grid_key_from_env + ENV["SEND_GRID_KEY"]? || raise_missing_key_message +end + +private def raise_missing_key_message + puts "Missing SEND_GRID_KEY. Set the SEND_GRID_KEY env variable to 'unused' if not sending emails, or set the SEND_GRID_KEY ENV var.".colorize.red + exit(1) +end diff --git a/config/env.cr b/config/env.cr new file mode 100644 index 0000000..3f36407 --- /dev/null +++ b/config/env.cr @@ -0,0 +1,33 @@ +# Environments are managed using `LuckyEnv`. By default, development, production +# and test are supported. See +# https://luckyframework.org/guides/getting-started/configuration for details. +# +# The default environment is development unless the environment variable +# LUCKY_ENV is set. +# +# Example: +# ``` +# LuckyEnv.environment # => "development" +# LuckyEnv.development? # => true +# LuckyEnv.production? # => false +# LuckyEnv.test? # => false +# ``` +# +# New environments can be added using the `LuckyEnv.add_env` macro. +# +# Example: +# ``` +# LuckyEnv.add_env :staging +# LuckyEnv.staging? # => false +# ``` +# +# To determine whether or not a `LuckyTask` is currently running, you can use +# the `LuckyEnv.task?` predicate. +# +# Example: +# ``` +# LuckyEnv.task? # => false +# ``` + +# Add a staging environment. +# LuckyEnv.add_env :staging diff --git a/config/error_handler.cr b/config/error_handler.cr new file mode 100644 index 0000000..c6b736e --- /dev/null +++ b/config/error_handler.cr @@ -0,0 +1,3 @@ +Lucky::ErrorHandler.configure do |settings| + settings.show_debug_output = !LuckyEnv.production? +end diff --git a/config/log.cr b/config/log.cr new file mode 100644 index 0000000..a43940d --- /dev/null +++ b/config/log.cr @@ -0,0 +1,50 @@ +require "file_utils" + +if LuckyEnv.test? + # Logs to `tmp/test.log` so you can see what's happening without having + # a bunch of log output in your spec results. + FileUtils.mkdir_p("tmp") + + backend = Log::IOBackend.new(File.new("tmp/test.log", mode: "w")) + backend.formatter = Lucky::PrettyLogFormatter.proc + Log.dexter.configure(:debug, backend) +elsif LuckyEnv.production? + # Lucky uses JSON in production so logs can be searched more easily + # + # If you want logs like in development use 'Lucky::PrettyLogFormatter.proc'. + backend = Log::IOBackend.new + backend.formatter = Dexter::JSONLogFormatter.proc + Log.dexter.configure(:info, backend) +else + # Use a pretty formatter printing to STDOUT in development + backend = Log::IOBackend.new + backend.formatter = Lucky::PrettyLogFormatter.proc + Log.dexter.configure(:debug, backend) + DB::Log.level = :info +end + +# Lucky only logs when before/after pipes halt by redirecting, or rendering a +# response. Pipes that run without halting are not logged. +# +# If you want to log every pipe that runs, set the log level to ':info' +Lucky::ContinuedPipeLog.dexter.configure(:none) + +# Lucky only logs failed queries by default. +# +# Set the log to ':info' to log all queries +Avram::QueryLog.dexter.configure(:none) + +# Subscribe to Pulsar events to log when queries are made, +# queries fail, or save operations fail. Remove this to +# disable these log events without disabling all logging. +Avram.initialize_logging + +# Skip logging static assets requests in development +Lucky::LogHandler.configure do |settings| + if LuckyEnv.development? + settings.skip_if = ->(context : HTTP::Server::Context) { + context.request.method.downcase == "get" && + context.request.resource.starts_with?(/\/css\/|\/js\/|\/assets\/|\/favicon\.ico/) + } + end +end diff --git a/config/route_helper.cr b/config/route_helper.cr new file mode 100644 index 0000000..ede1f32 --- /dev/null +++ b/config/route_helper.cr @@ -0,0 +1,10 @@ +# This is used when generating URLs for your application +Lucky::RouteHelper.configure do |settings| + if LuckyEnv.production? + # Example: https://my_app.com + settings.base_uri = ENV.fetch("APP_DOMAIN") + else + # Set domain to the default host/port in development/test + settings.base_uri = "http://localhost:#{Lucky::ServerSettings.port}" + end +end diff --git a/config/server.cr b/config/server.cr new file mode 100644 index 0000000..35ed11e --- /dev/null +++ b/config/server.cr @@ -0,0 +1,65 @@ +# Here is where you configure the Lucky server +# +# Look at config/route_helper.cr if you want to change the domain used when +# generating links with `Action.url`. +Lucky::Server.configure do |settings| + if LuckyEnv.production? + settings.secret_key_base = secret_key_from_env + settings.host = "0.0.0.0" + settings.port = ENV["PORT"].to_i + settings.gzip_enabled = true + # By default certain content types will be gzipped. + # For a full list look in + # https://github.com/luckyframework/lucky/blob/main/src/lucky/server.cr + # To add additional extensions do something like this: + # settings.gzip_content_types << "content/type" + else + settings.secret_key_base = "jTVqtEwZGvxIyVMudkpm+wkba4+LA1NW0YkOWxrnACE=" + # Change host/port in config/watch.yml + # Alternatively, you can set the DEV_PORT env to set the port for local development + settings.host = Lucky::ServerSettings.host + settings.port = Lucky::ServerSettings.port + end + + # By default Lucky will serve static assets in development and production. + # + # However you could use a CDN when in production like this: + # + # Lucky::Server.configure do |settings| + # if LuckyEnv.production? + # settings.asset_host = "https://mycdnhost.com" + # else + # settings.asset_host = "" + # end + # end + settings.asset_host = "" # Lucky will serve assets +end + +Lucky::ForceSSLHandler.configure do |settings| + # To force SSL in production, uncomment the lines below. + # This will cause http requests to be redirected to https: + # + # settings.enabled = LuckyEnv.production? + # settings.strict_transport_security = {max_age: 1.year, include_subdomains: true} + # + # Or, leave it disabled: + settings.enabled = false +end + +# Set a unique ID for each HTTP request. +# To enable the request ID, uncomment the lines below. +# You can set your own custom String, or use a random UUID. +# Lucky::RequestIdHandler.configure do |settings| +# settings.set_request_id = ->(context : HTTP::Server::Context) { +# UUID.random.to_s +# } +# end + +private def secret_key_from_env + ENV["SECRET_KEY_BASE"]? || raise_missing_secret_key_in_production +end + +private def raise_missing_secret_key_in_production + puts "Please set the SECRET_KEY_BASE environment variable. You can generate a secret key with 'lucky gen.secret_key'".colorize.red + exit(1) +end diff --git a/config/watch.yml b/config/watch.yml new file mode 100644 index 0000000..3a59b41 --- /dev/null +++ b/config/watch.yml @@ -0,0 +1,3 @@ +host: 127.0.0.1 +port: 3000 +reload_port: 3001 diff --git a/db/migrations/.keep b/db/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/db/migrations/00000000000001_create_users.cr b/db/migrations/00000000000001_create_users.cr new file mode 100644 index 0000000..96283bf --- /dev/null +++ b/db/migrations/00000000000001_create_users.cr @@ -0,0 +1,17 @@ +class CreateUsers::V00000000000001 < Avram::Migrator::Migration::V1 + def migrate + enable_extension "citext" + + create table_for(User) do + primary_key id : Int64 + add_timestamps + add email : String, unique: true, case_sensitive: false + add encrypted_password : String + end + end + + def rollback + drop table_for(User) + disable_extension "citext" + end +end diff --git a/docker/dev_entrypoint.sh b/docker/dev_entrypoint.sh new file mode 100755 index 0000000..edfa1e9 --- /dev/null +++ b/docker/dev_entrypoint.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +# Entry point for Lucky dev container + +warnfail () { + echo "$@" >&2 + exit 1 +} + +case ${1:-} in + "") ;; # no args → run default + *) exec "$@" ;; # args → run as command +esac + +if ! [ -d bin ] ; then + echo 'Creating bin directory' + mkdir bin +fi + +if ! shards check ; then + echo 'Installing shards...' + shards install +fi + +echo 'Waiting for postgres to be available...' + +./docker/wait-for-it.sh -q "${DB_HOST:-db}:${DB_PORT:-5432}" + +DB_USER="${DB_USERNAME:-postgres}" +DB_PASS="${DB_PASSWORD:-postgres}" +DB_NAME="${DB_NAME:-stem_development}" +DB_HOST="${DB_HOST:-db}" +DB_PORT="${DB_PORT:-5432}" + +PSQL_URL="postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}" + +# Run migrations only if migrations table does not exist +if ! PGPASSWORD="$DB_PASS" psql -U "$DB_USER" -h "$DB_HOST" -p "$DB_PORT" -d "$DB_NAME" -c '\d migrations' > /dev/null 2>&1 ; then + echo 'Finishing database setup (running migrations)...' + lucky db.migrate +fi + +echo 'Starting lucky dev server...' +exec lucky dev diff --git a/docker/development.dockerfile b/docker/development.dockerfile new file mode 100644 index 0000000..6010a65 --- /dev/null +++ b/docker/development.dockerfile @@ -0,0 +1,15 @@ +FROM crystallang/crystal:1.18.2 + +RUN apt-get update && \ + apt-get install -y wget postgresql-client tmux && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /lucky/cli +RUN git clone https://github.com/luckyframework/lucky_cli . && \ + git checkout v1.4.1 && \ + shards build --without-development && \ + cp bin/lucky /usr/bin + +WORKDIR /app +EXPOSE 3000 +EXPOSE 3001 diff --git a/docker/wait-for-it.sh b/docker/wait-for-it.sh new file mode 100755 index 0000000..f94821e --- /dev/null +++ b/docker/wait-for-it.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# +# Pulled from https://github.com/vishnubob/wait-for-it on 2022-02-28. +# Licensed under the MIT license as of 81b1373f. +# +# Below this line, wait-for-it is the original work of the author. +# +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/script/helpers/function_helpers.cr b/script/helpers/function_helpers.cr new file mode 100644 index 0000000..8abcb6a --- /dev/null +++ b/script/helpers/function_helpers.cr @@ -0,0 +1,32 @@ +require "colorize" + +# These are helper methods provided to help keep your code +# clean. Add new methods, or alter these as needed. + +def notice(message : String) : Nil + puts "\n▸ #{message}" +end + +def print_done : Nil + puts "✔ Done" +end + +def print_error(message : String) : Nil + puts "There is a problem with your system setup:\n".colorize.red.bold + puts "#{message}\n".colorize.red.bold + Process.exit(1) +end + +def command_not_found(command : String) : Bool + Process.find_executable(command).nil? +end + +def command_not_running(command : String, *args) : Bool + output = IO::Memory.new + code = Process.run(command, args, output: output).exit_code + code > 0 +end + +def run_command(command : String, *args) : Nil + Process.run(command, args, output: STDOUT, error: STDERR, input: STDIN) +end diff --git a/script/setup.cr b/script/setup.cr new file mode 100644 index 0000000..55f5d99 --- /dev/null +++ b/script/setup.cr @@ -0,0 +1,27 @@ +require "./helpers/*" + +notice "Running System Check" + +require "./system_check" + +print_done + +notice "Installing shards" +run_command "shards", "install" + +if !File.exists?(".env") + notice "No .env found. Creating one." + File.touch ".env" + print_done +end + +notice "Setting up the database" + +run_command "lucky", "db.setup" + +notice "Seeding the database with required and sample records" +run_command "lucky", "db.seed.required_data" +run_command "lucky", "db.seed.sample_data" + +print_done +notice "Run 'lucky dev' to start the app" diff --git a/script/system_check.cr b/script/system_check.cr new file mode 100644 index 0000000..861cf24 --- /dev/null +++ b/script/system_check.cr @@ -0,0 +1,17 @@ +require "./helpers/*" + +# Use this script to check the system for required tools and process that your app needs. +# A few helper functions are provided to keep the code simple. See the +# script/helpers/function_helpers.cr file for more examples. +# +# A few examples you might use here: +# * 'lucky db.verify_connection' to test postgres can be connected +# * Checking that elasticsearch, redis, or postgres is installed and/or booted +# * Note: Booting additional processes for things like mail, background jobs, etc... +# should go in your Procfile.dev. + +# CUSTOM PRE-BOOT CHECKS +# example: +# if command_not_running "redis-cli", "ping" +# print_error "Redis is not running." +# end diff --git a/shard.lock b/shard.lock index 93e4423..1a65ead 100644 --- a/shard.lock +++ b/shard.lock @@ -1,30 +1,102 @@ version: 2.0 shards: + authentic: + git: https://github.com/luckyframework/authentic.git + version: 1.0.2 + + avram: + git: https://github.com/luckyframework/avram.git + version: 1.4.2 + backtracer: git: https://github.com/sija/backtracer.cr.git version: 1.2.4 + bindata: + git: https://github.com/spider-gazelle/bindata.git + version: 2.1.1 + + cadmium_transliterator: + git: https://github.com/cadmiumcr/transliterator.git + version: 0.1.0+git.commit.46c4c14594057dbcfaf27e7e7c8c164d3f0ce3f1 + + carbon: + git: https://github.com/luckyframework/carbon.git + version: 0.6.1 + + carbon_sendgrid_adapter: + git: https://github.com/luckyframework/carbon_sendgrid_adapter.git + version: 0.6.0 + + cry: + git: https://github.com/luckyframework/cry.git + version: 0.4.3 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.13.1 - dotenv: - git: https://github.com/drum445/dotenv.git - version: 0.5.0 + dexter: + git: https://github.com/luckyframework/dexter.git + version: 0.3.4 exception_page: git: https://github.com/crystal-loot/exception_page.git version: 0.5.0 - kemal: - git: https://github.com/kemalcr/kemal.git - version: 1.8.0 + habitat: + git: https://github.com/luckyframework/habitat.git + version: 0.4.9 + + jwt: + git: https://github.com/crystal-community/jwt.git + version: 1.6.1 + + lucky: + git: https://github.com/luckyframework/lucky.git + version: 1.4.0 + + lucky_cache: + git: https://github.com/luckyframework/lucky_cache.git + version: 0.1.1 + + lucky_env: + git: https://github.com/luckyframework/lucky_env.git + version: 0.3.0 + + lucky_router: + git: https://github.com/luckyframework/lucky_router.git + version: 0.6.1 + + lucky_task: + git: https://github.com/luckyframework/lucky_task.git + version: 0.3.0 + + lucky_template: + git: https://github.com/luckyframework/lucky_template.git + version: 0.2.0 + + openssl_ext: + git: https://github.com/spider-gazelle/openssl_ext.git + version: 2.8.2 pg: git: https://github.com/will/crystal-pg.git version: 0.29.0 - radix: - git: https://github.com/luislavena/radix.git - version: 0.4.1 + pulsar: + git: https://github.com/luckyframework/pulsar.git + version: 0.2.3 + + shell-table: + git: https://github.com/luckyframework/shell-table.cr.git + version: 0.9.3 + + splay_tree_map: + git: https://github.com/wyhaines/splay_tree_map.cr.git + version: 0.3.0 + + wordsmith: + git: https://github.com/luckyframework/wordsmith.git + version: 0.5.0 diff --git a/shard.yml b/shard.yml index a9a9ad4..edc74ba 100644 --- a/shard.yml +++ b/shard.yml @@ -1,21 +1,33 @@ -name: stem +--- +name: stem_temp version: 0.1.0 - -authors: - - your-name-here - targets: - stem: - main: src/stem.cr - -dependencies: - kemal: - github: kemalcr/kemal - pg: - github: will/crystal-pg - dotenv: - github: drum445/dotenv - + stem_temp: + main: src/stem_temp.cr crystal: '>= 1.18.2' - -license: MIT +dependencies: + lucky: + github: luckyframework/lucky + version: ~> 1.4.0 + avram: + github: luckyframework/avram + version: ~> 1.4.0 + carbon: + github: luckyframework/carbon + version: ~> 0.6.0 + carbon_sendgrid_adapter: + github: luckyframework/carbon_sendgrid_adapter + version: ~> 0.6.0 + lucky_env: + github: luckyframework/lucky_env + version: ~> 0.3.0 + lucky_task: + github: luckyframework/lucky_task + version: ~> 0.3.0 + authentic: + github: luckyframework/authentic + version: '>= 1.0.2, < 2.0.0' + jwt: + github: crystal-community/jwt + version: ~> 1.6.1 +development_dependencies: {} diff --git a/spec/requests/api/me/show_spec.cr b/spec/requests/api/me/show_spec.cr new file mode 100644 index 0000000..0e1f91f --- /dev/null +++ b/spec/requests/api/me/show_spec.cr @@ -0,0 +1,17 @@ +require "../../../spec_helper" + +describe Api::Me::Show do + it "returns the signed in user" do + user = UserFactory.create + + response = ApiClient.auth(user).exec(Api::Me::Show) + + response.should send_json(200, email: user.email) + end + + it "fails if not authenticated" do + response = ApiClient.exec(Api::Me::Show) + + response.status_code.should eq(401) + end +end diff --git a/spec/requests/api/sign_ins/create_spec.cr b/spec/requests/api/sign_ins/create_spec.cr new file mode 100644 index 0000000..520c2df --- /dev/null +++ b/spec/requests/api/sign_ins/create_spec.cr @@ -0,0 +1,33 @@ +require "../../../spec_helper" + +describe Api::SignIns::Create do + it "returns a token" do + UserToken.stub_token("fake-token") do + user = UserFactory.create + + response = ApiClient.exec(Api::SignIns::Create, user: valid_params(user)) + + response.should send_json(200, token: "fake-token") + end + end + + it "returns an error if credentials are invalid" do + user = UserFactory.create + invalid_params = valid_params(user).merge(password: "incorrect") + + response = ApiClient.exec(Api::SignIns::Create, user: invalid_params) + + response.should send_json( + 400, + param: "password", + details: "password is wrong" + ) + end +end + +private def valid_params(user : User) + { + email: user.email, + password: "password", + } +end diff --git a/spec/requests/api/sign_ups/create_spec.cr b/spec/requests/api/sign_ups/create_spec.cr new file mode 100644 index 0000000..2a23542 --- /dev/null +++ b/spec/requests/api/sign_ups/create_spec.cr @@ -0,0 +1,34 @@ +require "../../../spec_helper" + +describe Api::SignUps::Create do + it "creates user on sign up" do + UserToken.stub_token("fake-token") do + response = ApiClient.exec(Api::SignUps::Create, user: valid_params) + + response.should send_json(200, token: "fake-token") + new_user = UserQuery.first + new_user.email.should eq(valid_params[:email]) + end + end + + it "returns error for invalid params" do + invalid_params = valid_params.merge(password_confirmation: "wrong") + + response = ApiClient.exec(Api::SignUps::Create, user: invalid_params) + + UserQuery.new.select_count.should eq(0) + response.should send_json( + 400, + param: "password_confirmation", + details: "password_confirmation must match" + ) + end +end + +private def valid_params + { + email: "test@email.com", + password: "password", + password_confirmation: "password", + } +end diff --git a/spec/setup/clean_database.cr b/spec/setup/clean_database.cr new file mode 100644 index 0000000..a1bc631 --- /dev/null +++ b/spec/setup/clean_database.cr @@ -0,0 +1,3 @@ +Spec.before_each do + AppDatabase.truncate +end diff --git a/spec/setup/reset_emails.cr b/spec/setup/reset_emails.cr new file mode 100644 index 0000000..140ab41 --- /dev/null +++ b/spec/setup/reset_emails.cr @@ -0,0 +1,3 @@ +Spec.before_each do + Carbon::DevAdapter.reset +end diff --git a/spec/setup/setup_database.cr b/spec/setup/setup_database.cr new file mode 100644 index 0000000..393c6da --- /dev/null +++ b/spec/setup/setup_database.cr @@ -0,0 +1,2 @@ +Db::Create.new(quiet: true).call +Db::Migrate.new(quiet: true).call diff --git a/spec/setup/start_app_server.cr b/spec/setup/start_app_server.cr new file mode 100644 index 0000000..3a64c70 --- /dev/null +++ b/spec/setup/start_app_server.cr @@ -0,0 +1,9 @@ +app_server = AppServer.new + +spawn do + app_server.listen +end + +Spec.after_suite do + app_server.close +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 34edb52..d876014 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,19 @@ +ENV["LUCKY_ENV"] = "test" +ENV["DEV_PORT"] = "5001" require "spec" -require "../src/stem" +require "../src/app" +require "./support/**" +require "../db/migrations/**" + +# Add/modify files in spec/setup to start/configure programs or run hooks +# +# By default there are scripts for setting up and cleaning the database, +# configuring LuckyFlow, starting the app server, etc. +require "./setup/**" + +include Carbon::Expectations +include Lucky::RequestExpectations + +Avram::Migrator::Runner.new.ensure_migrated! +Avram::SchemaEnforcer.ensure_correct_column_mappings! +Habitat.raise_if_missing_settings! diff --git a/spec/stem_spec.cr b/spec/stem_spec.cr deleted file mode 100644 index 2dff960..0000000 --- a/spec/stem_spec.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./spec_helper" - -describe Stem do - # TODO: Write tests - - it "works" do - false.should eq(true) - end -end diff --git a/spec/support/.keep b/spec/support/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/support/api_client.cr b/spec/support/api_client.cr new file mode 100644 index 0000000..46d449a --- /dev/null +++ b/spec/support/api_client.cr @@ -0,0 +1,12 @@ +class ApiClient < Lucky::BaseHTTPClient + app AppServer.new + + def initialize + super + headers("Content-Type": "application/json") + end + + def self.auth(user : User) + new.headers("Authorization": UserToken.generate(user)) + end +end diff --git a/spec/support/factories/.keep b/spec/support/factories/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/support/factories/user_factory.cr b/spec/support/factories/user_factory.cr new file mode 100644 index 0000000..bb837ee --- /dev/null +++ b/spec/support/factories/user_factory.cr @@ -0,0 +1,6 @@ +class UserFactory < Avram::Factory + def initialize + email "#{sequence("test-email")}@example.com" + encrypted_password Authentic.generate_encrypted_password("password") + end +end diff --git a/src/actions/api/me/show.cr b/src/actions/api/me/show.cr new file mode 100644 index 0000000..0060271 --- /dev/null +++ b/src/actions/api/me/show.cr @@ -0,0 +1,5 @@ +class Api::Me::Show < ApiAction + get "/api/me" do + json UserSerializer.new(current_user) + end +end diff --git a/src/actions/api/sign_ins/create.cr b/src/actions/api/sign_ins/create.cr new file mode 100644 index 0000000..3670356 --- /dev/null +++ b/src/actions/api/sign_ins/create.cr @@ -0,0 +1,13 @@ +class Api::SignIns::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ins" do + SignInUser.run(params) do |operation, user| + if user + json({token: UserToken.generate(user)}) + else + raise Avram::InvalidOperationError.new(operation) + end + end + end +end diff --git a/src/actions/api/sign_ups/create.cr b/src/actions/api/sign_ups/create.cr new file mode 100644 index 0000000..15bbd04 --- /dev/null +++ b/src/actions/api/sign_ups/create.cr @@ -0,0 +1,8 @@ +class Api::SignUps::Create < ApiAction + include Api::Auth::SkipRequireAuthToken + + post "/api/sign_ups" do + user = SignUpUser.create!(params) + json({token: UserToken.generate(user)}) + end +end diff --git a/src/actions/api_action.cr b/src/actions/api_action.cr new file mode 100644 index 0000000..a16fd09 --- /dev/null +++ b/src/actions/api_action.cr @@ -0,0 +1,17 @@ +# Include modules and add methods that are for all API requests +abstract class ApiAction < Lucky::Action + # APIs typically do not need to send cookie/session data. + # Remove this line if you want to send cookies in the response header. + disable_cookies + accepted_formats [:json] + + include Api::Auth::Helpers + + # By default all actions require sign in. + # Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests. + include Api::Auth::RequireAuthToken + + # By default all actions are required to use underscores to separate words. + # Add 'include Lucky::SkipRouteStyleCheck' to your actions if you wish to ignore this check for specific routes. + include Lucky::EnforceUnderscoredRoute +end diff --git a/src/actions/errors/show.cr b/src/actions/errors/show.cr new file mode 100644 index 0000000..a80eaa4 --- /dev/null +++ b/src/actions/errors/show.cr @@ -0,0 +1,42 @@ +# This class handles error responses and reporting. +# +# https://luckyframework.org/guides/http-and-routing/error-handling +class Errors::Show < Lucky::ErrorAction + DEFAULT_MESSAGE = "Something went wrong." + default_format :json + dont_report [Lucky::RouteNotFoundError, Avram::RecordNotFoundError] + + def render(error : Lucky::RouteNotFoundError | Avram::RecordNotFoundError) + error_json "Not found", status: 404 + end + + # When an InvalidOperationError is raised, show a helpful error with the + # param that is invalid, and what was wrong with it. + def render(error : Avram::InvalidOperationError) + error_json \ + message: error.renderable_message, + details: error.renderable_details, + param: error.invalid_attribute_name, + status: 400 + end + + # Always keep this below other 'render' methods or it may override your + # custom 'render' methods. + def render(error : Lucky::RenderableError) + error_json error.renderable_message, status: error.renderable_status + end + + # If none of the 'render' methods return a response for the raised Exception, + # Lucky will use this method. + def default_render(error : Exception) : Lucky::Response + error_json DEFAULT_MESSAGE, status: 500 + end + + private def error_json(message : String, status : Int, details = nil, param = nil) + json ErrorSerializer.new(message: message, details: details, param: param), status: status + end + + private def report(error : Exception) : Nil + # Send to Rollbar, send an email, etc. + end +end diff --git a/src/actions/home/index.cr b/src/actions/home/index.cr new file mode 100644 index 0000000..5a72b77 --- /dev/null +++ b/src/actions/home/index.cr @@ -0,0 +1,7 @@ +class Home::Index < ApiAction + include Api::Auth::SkipRequireAuthToken + + get "/" do + json({hello: "Hello World from Home::Index"}) + end +end diff --git a/src/actions/mixins/.keep b/src/actions/mixins/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/actions/mixins/api/auth/helpers.cr b/src/actions/mixins/api/auth/helpers.cr new file mode 100644 index 0000000..6b51cb5 --- /dev/null +++ b/src/actions/mixins/api/auth/helpers.cr @@ -0,0 +1,28 @@ +module Api::Auth::Helpers + # The 'memoize' macro makes sure only one query is issued to find the user + memoize def current_user? : User? + auth_token.try do |value| + user_from_auth_token(value) + end + end + + private def auth_token : String? + bearer_token || token_param + end + + private def bearer_token : String? + context.request.headers["Authorization"]? + .try(&.gsub("Bearer", "")) + .try(&.strip) + end + + private def token_param : String? + params.get?(:auth_token) + end + + private def user_from_auth_token(token : String) : User? + UserToken.decode_user_id(token).try do |user_id| + UserQuery.new.id(user_id).first? + end + end +end diff --git a/src/actions/mixins/api/auth/require_auth_token.cr b/src/actions/mixins/api/auth/require_auth_token.cr new file mode 100644 index 0000000..e018638 --- /dev/null +++ b/src/actions/mixins/api/auth/require_auth_token.cr @@ -0,0 +1,34 @@ +module Api::Auth::RequireAuthToken + macro included + before require_auth_token + end + + private def require_auth_token + if current_user? + continue + else + json auth_error_json, 401 + end + end + + private def auth_error_json + ErrorSerializer.new( + message: "Not authenticated.", + details: auth_error_details + ) + end + + private def auth_error_details : String + if auth_token + "The provided authentication token was incorrect." + else + "An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header." + end + end + + # Tells the compiler that the current_user is not nil since we have checked + # that the user is signed in + private def current_user : User + current_user?.as(User) + end +end diff --git a/src/actions/mixins/api/auth/skip_require_auth_token.cr b/src/actions/mixins/api/auth/skip_require_auth_token.cr new file mode 100644 index 0000000..68098cf --- /dev/null +++ b/src/actions/mixins/api/auth/skip_require_auth_token.cr @@ -0,0 +1,10 @@ +module Api::Auth::SkipRequireAuthToken + macro included + skip require_auth_token + end + + # Since sign in is not required, current_user might be nil + def current_user : User? + current_user? + end +end diff --git a/src/app.cr b/src/app.cr new file mode 100644 index 0000000..21fd1ea --- /dev/null +++ b/src/app.cr @@ -0,0 +1,20 @@ +require "./shards" + +require "../config/server" +require "./app_database" +require "../config/**" +require "./models/base_model" +require "./models/mixins/**" +require "./models/**" +require "./queries/mixins/**" +require "./queries/**" +require "./operations/mixins/**" +require "./operations/**" +require "./serializers/base_serializer" +require "./serializers/**" +require "./emails/base_email" +require "./emails/**" +require "./actions/mixins/**" +require "./actions/**" +require "../db/migrations/**" +require "./app_server" diff --git a/src/app_database.cr b/src/app_database.cr new file mode 100644 index 0000000..0efd4f5 --- /dev/null +++ b/src/app_database.cr @@ -0,0 +1,2 @@ +class AppDatabase < Avram::Database +end diff --git a/src/app_server.cr b/src/app_server.cr new file mode 100644 index 0000000..53f483d --- /dev/null +++ b/src/app_server.cr @@ -0,0 +1,28 @@ +class AppServer < Lucky::BaseAppServer + # Learn about middleware with HTTP::Handlers: + # https://luckyframework.org/guides/http-and-routing/http-handlers + def middleware : Array(HTTP::Handler) + [ + Lucky::RequestIdHandler.new, + Lucky::ForceSSLHandler.new, + Lucky::HttpMethodOverrideHandler.new, + Lucky::LogHandler.new, + Lucky::ErrorHandler.new(action: Errors::Show), + Lucky::RemoteIpHandler.new, + Lucky::RouteHandler.new, + + # Disabled in API mode: + # Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"), + # Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false), + Lucky::RouteNotFoundHandler.new, + ] of HTTP::Handler + end + + def protocol + "http" + end + + def listen + server.listen(host, port, reuse_port: false) + end +end diff --git a/src/emails/base_email.cr b/src/emails/base_email.cr new file mode 100644 index 0000000..656f4f1 --- /dev/null +++ b/src/emails/base_email.cr @@ -0,0 +1,15 @@ +# Learn about sending emails +# https://luckyframework.org/guides/emails/sending-emails-with-carbon +abstract class BaseEmail < Carbon::Email + # You can add defaults using the 'inherited' hook + # + # Example: + # + # macro inherited + # from default_from + # end + # + # def default_from + # Carbon::Address.new("support@app.com") + # end +end diff --git a/src/models/base_model.cr b/src/models/base_model.cr new file mode 100644 index 0000000..6bafeb8 --- /dev/null +++ b/src/models/base_model.cr @@ -0,0 +1,5 @@ +abstract class BaseModel < Avram::Model + def self.database : Avram::Database.class + AppDatabase + end +end diff --git a/src/models/mixins/.keep b/src/models/mixins/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/models/user.cr b/src/models/user.cr new file mode 100644 index 0000000..39729bb --- /dev/null +++ b/src/models/user.cr @@ -0,0 +1,13 @@ +class User < BaseModel + include Carbon::Emailable + include Authentic::PasswordAuthenticatable + + table do + column email : String + column encrypted_password : String + end + + def emailable : Carbon::Address + Carbon::Address.new(email) + end +end diff --git a/src/models/user_token.cr b/src/models/user_token.cr new file mode 100644 index 0000000..6586303 --- /dev/null +++ b/src/models/user_token.cr @@ -0,0 +1,30 @@ +# Generates and decodes JSON Web Tokens for Authenticating users. +class UserToken + Habitat.create { setting stubbed_token : String? } + ALGORITHM = JWT::Algorithm::HS256 + + def self.generate(user : User) : String + payload = {"user_id" => user.id} + + settings.stubbed_token || create_token(payload) + end + + def self.create_token(payload) + JWT.encode(payload, Lucky::Server.settings.secret_key_base, ALGORITHM) + end + + def self.decode_user_id(token : String) : Int64? + payload, _header = JWT.decode(token, Lucky::Server.settings.secret_key_base, ALGORITHM) + payload["user_id"].to_s.to_i64 + rescue e : JWT::Error + Lucky::Log.dexter.error { {jwt_decode_error: e.message} } + nil + end + + # Used in tests to return a fake token to test against. + def self.stub_token(token : String, &) + temp_config(stubbed_token: token) do + yield + end + end +end diff --git a/src/operations/.keep b/src/operations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/operations/mixins/.keep b/src/operations/mixins/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/operations/mixins/password_validations.cr b/src/operations/mixins/password_validations.cr new file mode 100644 index 0000000..c56b975 --- /dev/null +++ b/src/operations/mixins/password_validations.cr @@ -0,0 +1,12 @@ +module PasswordValidations + macro included + before_save run_password_validations + end + + private def run_password_validations + validate_required password, password_confirmation + validate_confirmation_of password, with: password_confirmation + # 72 is a limitation of BCrypt + validate_size_of password, min: 6, max: 72 + end +end diff --git a/src/operations/mixins/user_from_email.cr b/src/operations/mixins/user_from_email.cr new file mode 100644 index 0000000..862fa9a --- /dev/null +++ b/src/operations/mixins/user_from_email.cr @@ -0,0 +1,7 @@ +module UserFromEmail + private def user_from_email : User? + email.value.try do |value| + UserQuery.new.email(value).first? + end + end +end diff --git a/src/operations/request_password_reset.cr b/src/operations/request_password_reset.cr new file mode 100644 index 0000000..4941aa7 --- /dev/null +++ b/src/operations/request_password_reset.cr @@ -0,0 +1,25 @@ +class RequestPasswordReset < Avram::Operation + # You can modify this in src/operations/mixins/user_from_email.cr + include UserFromEmail + + attribute email : String + + # Run validations and yield the operation and the user if valid + def run + user = user_from_email + validate(user) + + if valid? + user + else + nil + end + end + + def validate(user : User?) + validate_required email + if user.nil? + email.add_error "is not in our system" + end + end +end diff --git a/src/operations/reset_password.cr b/src/operations/reset_password.cr new file mode 100644 index 0000000..3bdd3c8 --- /dev/null +++ b/src/operations/reset_password.cr @@ -0,0 +1,11 @@ +class ResetPassword < User::SaveOperation + # Change password validations in src/operations/mixins/password_validations.cr + include PasswordValidations + + attribute password : String + attribute password_confirmation : String + + before_save do + Authentic.copy_and_encrypt password, to: encrypted_password + end +end diff --git a/src/operations/sign_in_user.cr b/src/operations/sign_in_user.cr new file mode 100644 index 0000000..de80342 --- /dev/null +++ b/src/operations/sign_in_user.cr @@ -0,0 +1,40 @@ +class SignInUser < Avram::Operation + param_key :user + # You can modify this in src/operations/mixins/user_from_email.cr + include UserFromEmail + + attribute email : String + attribute password : String + + # Run validations and yields the operation and the user if valid + def run + user = user_from_email + validate_credentials(user) + + if valid? + user + else + nil + end + end + + # `validate_credentials` determines if a user can sign in. + # + # If desired, you can add additional checks in this method, e.g. + # + # if user.locked? + # email.add_error "is locked out" + # end + private def validate_credentials(user) + if user + unless Authentic.correct_password?(user, password.value.to_s) + password.add_error "is wrong" + end + else + # Usually ok to say that an email is not in the system: + # https://kev.inburke.com/kevin/invalid-username-or-password-useless/ + # https://github.com/luckyframework/lucky_cli/issues/192 + email.add_error "is not in our system" + end + end +end diff --git a/src/operations/sign_up_user.cr b/src/operations/sign_up_user.cr new file mode 100644 index 0000000..8c46fad --- /dev/null +++ b/src/operations/sign_up_user.cr @@ -0,0 +1,14 @@ +class SignUpUser < User::SaveOperation + param_key :user + # Change password validations in src/operations/mixins/password_validations.cr + include PasswordValidations + + permit_columns email + attribute password : String + attribute password_confirmation : String + + before_save do + validate_uniqueness_of email + Authentic.copy_and_encrypt(password, to: encrypted_password) if password.valid? + end +end diff --git a/src/queries/.keep b/src/queries/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/queries/mixins/.keep b/src/queries/mixins/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/queries/user_query.cr b/src/queries/user_query.cr new file mode 100644 index 0000000..8a7e9a7 --- /dev/null +++ b/src/queries/user_query.cr @@ -0,0 +1,2 @@ +class UserQuery < User::BaseQuery +end diff --git a/src/serializers/.keep b/src/serializers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/serializers/base_serializer.cr b/src/serializers/base_serializer.cr new file mode 100644 index 0000000..1702f27 --- /dev/null +++ b/src/serializers/base_serializer.cr @@ -0,0 +1,9 @@ +abstract class BaseSerializer + include Lucky::Serializable + + def self.for_collection(collection : Enumerable, *args, **named_args) : Array(self) + collection.map do |object| + new(object, *args, **named_args) + end + end +end diff --git a/src/serializers/error_serializer.cr b/src/serializers/error_serializer.cr new file mode 100644 index 0000000..b7b5528 --- /dev/null +++ b/src/serializers/error_serializer.cr @@ -0,0 +1,14 @@ +# This is the default error serializer generated by Lucky. +# Feel free to customize it in any way you like. +class ErrorSerializer < BaseSerializer + def initialize( + @message : String, + @details : String? = nil, + @param : String? = nil, # so you can track which param (if any) caused the problem + ) + end + + def render + {message: @message, param: @param, details: @details} + end +end diff --git a/src/serializers/user_serializer.cr b/src/serializers/user_serializer.cr new file mode 100644 index 0000000..1a86f14 --- /dev/null +++ b/src/serializers/user_serializer.cr @@ -0,0 +1,8 @@ +class UserSerializer < BaseSerializer + def initialize(@user : User) + end + + def render + {email: @user.email} + end +end diff --git a/src/shards.cr b/src/shards.cr new file mode 100644 index 0000000..7cadec1 --- /dev/null +++ b/src/shards.cr @@ -0,0 +1,10 @@ +# Load .env file before any other config or app code +require "lucky_env" +LuckyEnv.load?(".env") + +# Require your shards here +require "lucky" +require "avram/lucky" +require "carbon" +require "authentic" +require "jwt" diff --git a/src/start_server.cr b/src/start_server.cr new file mode 100644 index 0000000..de8af78 --- /dev/null +++ b/src/start_server.cr @@ -0,0 +1,17 @@ +require "./app" + +Habitat.raise_if_missing_settings! + +if LuckyEnv.development? + Avram::Migrator::Runner.new.ensure_migrated! + Avram::SchemaEnforcer.ensure_correct_column_mappings! +end + +app_server = AppServer.new +puts "Listening on http://#{app_server.host}:#{app_server.port}" + +Signal::INT.trap do + app_server.close +end + +app_server.listen diff --git a/src/stem.cr b/src/stem.cr deleted file mode 100644 index 733ae98..0000000 --- a/src/stem.cr +++ /dev/null @@ -1,538 +0,0 @@ -# src/stem.cr -require "kemal" -require "pg" -require "json" -require "uri" -require "dotenv" - -# ============ -# DB bootstrap -# ============ -module Stem - VERSION = "0.1.0" - - Dotenv.load - DEFAULT_DB_URL = "postgres://postgres:postgres@127.0.0.1:5432/florex" - - @@db : DB::Database? - - def self.db : DB::Database - @@db ||= DB.open(ENV["DATABASE_URL"]? || DEFAULT_DB_URL) - end - - def self.migrate! - db = self.db - - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS services ( - id BIGSERIAL PRIMARY KEY, - name TEXT UNIQUE NOT NULL, - kind TEXT NOT NULL, - url TEXT, - status TEXT NOT NULL DEFAULT 'ok', - avg_response_ms INT NOT NULL DEFAULT 0, - last_check TIMESTAMPTZ, - check_interval_seconds INT NOT NULL DEFAULT 60, - timeout_seconds INT NOT NULL DEFAULT 30, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - SQL - - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS service_metrics ( - id BIGSERIAL PRIMARY KEY, - service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, - ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), - response_ms INT NOT NULL, - status TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_service_metrics_service_ts ON service_metrics(service_id, ts DESC); - SQL - - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS logs ( - id BIGSERIAL PRIMARY KEY, - service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, - ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), - message TEXT NOT NULL, - severity TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON logs(service_id, ts DESC); - CREATE INDEX IF NOT EXISTS idx_logs_severity_ts ON logs(severity, ts DESC); - SQL - - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS alerts ( - id BIGSERIAL PRIMARY KEY, - service_id BIGINT NOT NULL REFERENCES services(id) ON DELETE CASCADE, - severity TEXT NOT NULL, - message TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ - ); - CREATE INDEX IF NOT EXISTS idx_alerts_open ON alerts(service_id) WHERE resolved_at IS NULL; - SQL - - db.exec <<-SQL - CREATE TABLE IF NOT EXISTS alert_channels ( - id BIGSERIAL PRIMARY KEY, - kind TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ); - CREATE UNIQUE INDEX IF NOT EXISTS uniq_alert_channels_kind ON alert_channels(kind); - SQL - end - - def self.seed! - db = self.db - - # Services - count = db.scalar("SELECT COUNT(*) FROM services").as(Int64) - if count == 0 - db.exec <<-SQL - INSERT INTO services (name, kind, url, status, avg_response_ms, last_check, check_interval_seconds, timeout_seconds) - VALUES - ('rails-api', 'Rails', 'https://api.example.com', 'ok', 145, NOW() - INTERVAL '2 minutes', 60, 30), - ('node-worker', 'Node.js', 'https://worker.example.com', 'ok', 89, NOW() - INTERVAL '1 minutes', 60, 30), - ('auth-service', 'Rails', 'https://auth.example.com', 'warning', 312, NOW() - INTERVAL '3 minutes', 60, 30), - ('websocket-server', 'Node.js', 'wss://ws.example.com', 'ok', 52, NOW() - INTERVAL '1 minutes', 30, 10), - ('payment-gateway', 'Rails', 'https://pay.example.com', 'ok', 198, NOW() - INTERVAL '2 minutes', 60, 30), - ('email-processor', 'Node.js', 'https://mail.example.com', 'error', 0, NOW() - INTERVAL '5 minutes', 60, 30) - SQL - - # Seed some metrics (last 12 points-ish) - db.query "SELECT id, name FROM services" do |rs| - while rs.move_next - sid = rs.read(Int64) - name = rs.read(String) - # simple synthetic metrics - 12.times do |i| - delay_ms = case name - when "email-processor" then i < 5 ? 40 + 5*i : 0 - when "auth-service" then 220 + (i*15) - when "rails-api" then 120 + (i%5)*5 - when "node-worker" then 80 + (i%5)*3 - when "websocket-server" then 50 + (i%5)*2 - when "payment-gateway" then 160 + (i%5)*8 - else 100 - end - st = delay_ms == 0 ? "error" : (delay_ms > 250 ? "warning" : "ok") - db.exec "INSERT INTO service_metrics (service_id, ts, response_ms, status) VALUES ($1, NOW() - ($2 || ' minutes')::interval, $3, $4)", - sid, (12 - i), delay_ms, st - end - end - end - - # Alerts - db.exec <<-SQL - INSERT INTO alerts (service_id, severity, message, created_at) - SELECT s.id, 'critical', 'Service is down - Connection timeout', NOW() - FROM services s WHERE s.name = 'email-processor'; - INSERT INTO alerts (service_id, severity, message, created_at) - SELECT s.id, 'warning', 'High response time detected (>300ms)', NOW() - INTERVAL '5 minutes' - FROM services s WHERE s.name = 'auth-service'; - INSERT INTO alerts (service_id, severity, message, created_at) - SELECT s.id, 'info', 'Service recovered', NOW() - INTERVAL '30 minutes' - FROM services s WHERE s.name = 'rails-api'; - SQL - - # Logs - db.exec <<-SQL - INSERT INTO logs (service_id, ts, message, severity) - SELECT s.id, NOW() - INTERVAL '0 minutes', 'Connection timeout after 30 seconds', 'critical' - FROM services s WHERE s.name = 'email-processor'; - INSERT INTO logs (service_id, ts, message, severity) - SELECT s.id, NOW() - INTERVAL '1 minutes', 'Retry attempt 3/3 failed', 'error' - FROM services s WHERE s.name = 'email-processor'; - INSERT INTO logs (service_id, ts, message, severity) - SELECT s.id, NOW() - INTERVAL '5 minutes', 'Response time 312ms exceeds threshold', 'warning' - FROM services s WHERE s.name = 'auth-service'; - INSERT INTO logs (service_id, ts, message, severity) - SELECT s.id, NOW() - INTERVAL '8 minutes', 'Health check passed', 'info' - FROM services s WHERE s.name = 'rails-api'; - SQL - - # Alert channels - db.exec <<-SQL - INSERT INTO alert_channels (kind, enabled, config) - VALUES - ('email', TRUE, '{"recipients": ["admin@example.com"]}'), - ('slack', TRUE, '{"webhook_url": "https://hooks.slack.com/services/..."}'), - ('telegram', FALSE, '{"bot_token": "", "chat_id": ""}'), - ('mattermost', FALSE, '{"webhook_url": ""}') - ON CONFLICT (kind) DO NOTHING; - SQL - end - end -end - -# ================== -# DTOs (camelCased) -# ================== -struct ServiceDTO - include JSON::Serializable - - property id : Int64 - property name : String - @[JSON::Field(key: "type")] - property kind : String - property status : String - @[JSON::Field(key: "responseTime")] - property response_time : Int32 - @[JSON::Field(key: "lastCheck")] - property last_check_human : String - property sparkline : Array(Int32) - - def initialize( - @id : Int64, - @name : String, - @kind : String, - @status : String, - @response_time : Int32, - @last_check_human : String, - @sparkline : Array(Int32) - ); end -end - -struct LogDTO - include JSON::Serializable - - property id : Int64 - property timestamp : String - property service : String - property message : String - property severity : String - - def initialize( - @id : Int64, - @timestamp : String, - @service : String, - @message : String, - @severity : String - ); end -end - -struct AlertDTO - include JSON::Serializable - - property id : Int64 - property service : String - property message : String - property severity : String - property timestamp : String - - def initialize( - @id : Int64, - @service : String, - @message : String, - @severity : String, - @timestamp : String - ); end -end - -struct AlertChannelDTO - include JSON::Serializable - - property id : Int64 - property name : String - property enabled : Bool - property config : JSON::Any - @[JSON::Field(key: "icon")] - property icon_path : String - - def initialize( - @id : Int64, - @name : String, - @enabled : Bool, - @config : JSON::Any, - @icon_path : String - ); end -end - -# =========== -# Utilities -# =========== -def time_ago(ts : Time?) : String - return "—" unless ts - diff = Time.utc - ts - mins = (diff.total_minutes).to_i - return "#{mins} min ago" if mins < 60 - hours = (diff.total_hours).to_i - return "#{hours} hour ago" if hours == 1 - return "#{hours} hours ago" if hours < 24 - days = (diff.total_days).to_i - days == 1 ? "1 day ago" : "#{days} days ago" -end - -def normalize_spark(values : Array(Int32)) : Array(Int32) - max = values.max? || 1 - return values.map { |v| 0 } if max == 0 - values.map { |v| ((v.to_f / max) * 100.0).round.to_i } -end - -def icon_for_channel(kind : String) : String - case kind - when "email" - "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" - when "slack" - "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" - when "telegram" - "M12 19l9 2-9-18-9 18 9-2zm0 0v-8" - when "mattermost" - "M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" - else - "" - end -end - -# ================== -# CORS + JSON helper -# ================== -before_all do |env| - env.response.headers["Access-Control-Allow-Origin"] = ENV["CORS_ORIGIN"]? || "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" -end - -options "/*" do |env| - env.response.status_code = 204 - "" -end - -def json(env, payload, status = 200) - env.response.status_code = status - env.response.content_type = "application/json" - payload.to_json -end - -# ========= -# Routes -# ========= -get "/api/health" { |env| json(env, {ok: true, version: Stem::VERSION}) } - -# Summary for hero cards -get "/api/status" do |env| - db = Stem.db - total = db.scalar("SELECT COUNT(*) FROM services").as(Int64) - healthy = db.scalar("SELECT COUNT(*) FROM services WHERE status = 'ok'").as(Int64) - active_alerts = db.scalar("SELECT COUNT(*) FROM alerts WHERE resolved_at IS NULL AND severity IN ('critical','error')").as(Int64) rescue 0_i64 - uptime = 99.8 # placeholder – compute properly later from metrics - - json(env, { - services: total, - healthy: healthy, - activeAlerts: active_alerts, - uptimePercent: uptime, - }) -end - -# List services (with sparkline) -get "/api/services" do |env| - db = Stem.db - services = [] of ServiceDTO - - db.query "SELECT id, name, kind, status, avg_response_ms, last_check FROM services ORDER BY name" do |rs| - while rs.move_next - id = rs.read(Int64) - name = rs.read(String) - kind = rs.read(String) - status = rs.read(String) - avg_rt = rs.read(Int32) - last_check = rs.read(Time?) - # load last 12 response_ms for sparkline - points = [] of Int32 - db.query "SELECT response_ms FROM service_metrics WHERE service_id = $1 ORDER BY ts DESC LIMIT 12", id do |rs2| - while rs2.move_next - points << rs2.read(Int32) - end - end - points = points.reverse - services << ServiceDTO.new( - id: id, - name: name, - kind: kind, - status: status, - response_time: avg_rt, - last_check_human: time_ago(last_check), - sparkline: normalize_spark(points) - ) - end - end - - json(env, services) -end - -# Service detail + last 24h points -get "/api/services/:id" do |env| - db = Stem.db - id = env.params.url["id"].to_i64 - - row = db.query_one? "SELECT id, name, kind, status, avg_response_ms, last_check FROM services WHERE id = $1", id, as: { - Int64, String, String, String, Int32, Time? - } - - unless row - env.response.status_code = 404 - next json(env, {error: "Service not found"}) - end - - sid, name, kind, status, avg_rt, last_check = row - - points = [] of Int32 - db.query "SELECT response_ms FROM service_metrics WHERE service_id = $1 AND ts >= NOW() - INTERVAL '24 hours' ORDER BY ts ASC", id do |rs2| - while rs2.move_next - points << rs2.read(Int32) - end - end - - dto = ServiceDTO.new( - id: sid, - name: name, - kind: kind, - status: status, - response_time: avg_rt, - last_check_human: time_ago(last_check), - sparkline: normalize_spark(points.last(12)) # keep last 12 for UI - ) - - json(env, dto) -end - -# Create a service (simple) -post "/api/services" do |env| - body = env.request.body.try &.gets_to_end - unless body - next json(env, {error: "Empty body"}, 400) - end - data = JSON.parse(body) - name = data["name"].as_s - kind = data["type"]?.try(&.as_s) || "Generic" - url = data["url"]?.try(&.as_s) - check_interval = data["checkInterval"]?.try(&.as_i) || 60 - timeout = data["timeout"]?.try(&.as_i) || 30 - - db = Stem.db - db.exec "INSERT INTO services (name, kind, url, status, avg_response_ms, last_check, check_interval_seconds, timeout_seconds) VALUES ($1, $2, $3, 'ok', 0, NOW(), $4, $5)", - name, kind, url, check_interval, timeout - - json(env, {ok: true}) -end - -# Delete service -delete "/api/services/:id" do |env| - id = env.params.url["id"].to_i64 - db = Stem.db - db.exec "DELETE FROM services WHERE id = $1", id - json(env, {ok: true}) -end - -# Logs list + filtering: /api/logs?search=&severity=&service=&limit=100 -# Ingest a log (MVP) -post "/api/logs" do |env| - body = env.request.body.try &.gets_to_end - data = body ? JSON.parse(body) : JSON.parse("{}") - service = data["service"]?.try(&.as_s) - message = data["message"]?.try(&.as_s) || "" - severity = data["severity"]?.try(&.as_s) || "info" - - unless service - next json(env, {error: "service is required"}, 400) - end - - db = Stem.db - - if sid = db.query_one? "SELECT id FROM services WHERE name = $1", service, as: Int64 - db.exec "INSERT INTO logs (service_id, message, severity) VALUES ($1, $2, $3)", - sid, message, severity - json(env, {ok: true}) - else - json(env, {error: "Unknown service"}, 404) - end -end - -# Alerts list (recent, unresolved first) -get "/api/alerts" do |env| - db = Stem.db - alerts = [] of AlertDTO - db.query <<-SQL do |rs| - SELECT a.id, s.name, a.message, a.severity, a.created_at - FROM alerts a - JOIN services s ON s.id = a.service_id - ORDER BY (a.resolved_at IS NULL) DESC, a.created_at DESC - LIMIT 200 - SQL - while rs.move_next - id = rs.read(Int64) - svc = rs.read(String) - msg = rs.read(String) - sev = rs.read(String) - ts = rs.read(Time) - alerts << AlertDTO.new( - id: id, - service: svc, - message: msg, - severity: sev, - timestamp: ts.to_s("%Y-%m-%d %H:%M:%S") - ) - end - end - - json(env, alerts) -end - -# Alert channels config (maps to your UI) -get "/api/alerts/channels" do |env| - db = Stem.db - channels = [] of AlertChannelDTO - db.query "SELECT id, kind, enabled, config FROM alert_channels ORDER BY id" do |rs| - while rs.move_next - id = rs.read(Int64) - kind = rs.read(String) - enabled = rs.read(Bool) - config = JSON.parse(rs.read(String)) - channels << AlertChannelDTO.new( - id: id, - name: kind.capitalize, - enabled: enabled, - config: config, - icon_path: icon_for_channel(kind) - ) - end - end - json(env, channels) -end - -# Update a channel -put "/api/alerts/channels/:id" do |env| - id = env.params.url["id"].to_i64 - body = env.request.body.try &.gets_to_end - unless body - next json(env, {error: "Empty body"}, 400) - end - data = JSON.parse(body) - enabled = data["enabled"]?.try(&.as_bool) - config = data["config"]? || JSON.parse("{}") - - db = Stem.db - if enabled.nil? - db.exec "UPDATE alert_channels SET config = $1::jsonb, updated_at = NOW() WHERE id = $2", config.to_json, id - else - db.exec "UPDATE alert_channels SET enabled = $1, config = $2::jsonb, updated_at = NOW() WHERE id = $3", enabled.as(Bool), config.to_json, id - end - json(env, {ok: true}) -end - -# Start server -# Kemal.config.logger = Kemal::BaseLogHandler#.new(STDOUT) -post "/__admin/seed" { |env| Stem.seed!; json(env, {ok: true}) } - -Stem.migrate! -Stem.seed! -Kemal.config.host = "0.0.0.0" -Kemal.config.port = (ENV["PORT"]? || "3000").to_i - -Kemal.run(ENV["PORT"]?.try(&.to_i) || 3000) diff --git a/src/stem_temp.cr b/src/stem_temp.cr new file mode 100644 index 0000000..68e1a8d --- /dev/null +++ b/src/stem_temp.cr @@ -0,0 +1,6 @@ +# Typically you will not use or modify this file. 'shards build' and some +# other crystal tools will sometimes use this. +# +# When this file is compiled/run it will require and run 'start_server', +# which as its name implies will start the server for you app. +require "./start_server" diff --git a/tasks.cr b/tasks.cr new file mode 100644 index 0000000..5a892d4 --- /dev/null +++ b/tasks.cr @@ -0,0 +1,25 @@ +# This file loads your app and all your tasks when running 'lucky' +# +# Run 'lucky --help' to see all available tasks. +# +# Learn to create your own tasks: +# https://luckyframework.org/guides/command-line-tasks/custom-tasks + +# See `LuckyEnv#task?` +ENV["LUCKY_TASK"] = "true" + +# Load Lucky and the app (actions, models, etc.) +require "./src/app" +require "lucky_task" + +# You can add your own tasks here in the ./tasks folder +require "./tasks/**" + +# Load migrations +require "./db/migrations/**" + +# Load Lucky tasks (dev, routes, etc.) +require "lucky/tasks/**" +require "avram/lucky/tasks" + +LuckyTask::Runner.run diff --git a/tasks/.keep b/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tasks/db/seed/required_data.cr b/tasks/db/seed/required_data.cr new file mode 100644 index 0000000..d866040 --- /dev/null +++ b/tasks/db/seed/required_data.cr @@ -0,0 +1,30 @@ +require "../../../spec/support/factories/**" + +# Add seeds here that are *required* for your app to work. +# For example, you might need at least one admin user or you might need at least +# one category for your blog posts for the app to work. +# +# Use `Db::Seed::SampleData` if your only want to add sample data helpful for +# development. +class Db::Seed::RequiredData < LuckyTask::Task + summary "Add database records required for the app to work" + + def call + # Using a Avram::Factory: + # + # Use the defaults, but override just the email + # UserFactory.create &.email("me@example.com") + + # Using a SaveOperation: + # + # SaveUser.create!(email: "me@example.com", name: "Jane") + # + # You likely want to be able to run this file more than once. To do that, + # only create the record if it doesn't exist yet: + # + # unless UserQuery.new.email("me@example.com").first? + # SaveUser.create!(email: "me@example.com", name: "Jane") + # end + puts "Done adding required data" + end +end diff --git a/tasks/db/seed/sample_data.cr b/tasks/db/seed/sample_data.cr new file mode 100644 index 0000000..231d7e8 --- /dev/null +++ b/tasks/db/seed/sample_data.cr @@ -0,0 +1,30 @@ +require "../../../spec/support/factories/**" + +# Add sample data helpful for development, e.g. (fake users, blog posts, etc.) +# +# Use `Db::Seed::RequiredData` if you need to create data *required* for your +# app to work. +class Db::Seed::SampleData < LuckyTask::Task + summary "Add sample database records helpful for development" + + def call + # Using an Avram::Factory: + # + # Use the defaults, but override just the email + # UserFactory.create &.email("me@example.com") + + # Using a SaveOperation: + # ``` + # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123") + # ``` + # + # You likely want to be able to run this file more than once. To do that, + # only create the record if it doesn't exist yet: + # ``` + # if UserQuery.new.email("me@example.com").none? + # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123") + # end + # ``` + puts "Done adding sample data" + end +end From 6a4be4904789b0d4309e0c240f583340619407bb Mon Sep 17 00:00:00 2001 From: unurgunite Date: Thu, 27 Nov 2025 16:06:15 +0300 Subject: [PATCH 3/6] Replaced `stem_temp` with `stem` --- .github/workflows/ci.yml | 2 +- README.md | 2 +- config/cookies.cr | 2 +- shard.yml | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dfbfc6..5fafaef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: stem_temp CI +name: stem CI on: push: diff --git a/README.md b/README.md index 476001b..db7b6c6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# stem_temp +# stem This is a project written using [Lucky](https://luckyframework.org). Enjoy! diff --git a/config/cookies.cr b/config/cookies.cr index 9e47480..6ac435e 100644 --- a/config/cookies.cr +++ b/config/cookies.cr @@ -1,7 +1,7 @@ require "./server" Lucky::Session.configure do |settings| - settings.key = "_stem_temp_session" + settings.key = "_stem_session" end Lucky::CookieJar.configure do |settings| diff --git a/shard.yml b/shard.yml index edc74ba..da4ca40 100644 --- a/shard.yml +++ b/shard.yml @@ -1,9 +1,9 @@ --- -name: stem_temp +name: stem version: 0.1.0 targets: - stem_temp: - main: src/stem_temp.cr + stem: + main: src/stem.cr crystal: '>= 1.18.2' dependencies: lucky: From 97f2bcbfa367d50f0ef837e39ccfe96caedab391 Mon Sep 17 00:00:00 2001 From: unurgunite Date: Fri, 28 Nov 2025 00:15:16 +0300 Subject: [PATCH 4/6] Added initial endpoints and data --- .../20251127131707_create_services.cr | 26 ++++++++++++ src/actions/api/health/show.cr | 5 +++ src/actions/api/services/create.cr | 21 ++++++++++ src/actions/api/services/delete.cr | 11 +++++ src/actions/api/services/index.cr | 8 ++++ src/actions/api_action.cr | 1 + src/app.cr | 2 + src/app_server.cr | 1 + src/helpers/time_helper.cr | 13 ++++++ src/middleware/cors_handler.cr | 17 ++++++++ src/models/service.cr | 12 ++++++ src/operations/delete_service.cr | 4 ++ src/operations/save_service.cr | 6 +++ src/queries/service_query.cr | 2 + src/serializers/service_serializer.cr | 15 +++++++ src/{stem_temp.cr => stem.cr} | 0 tasks/db/seed/sample_data.cr | 41 +++++++++++-------- 17 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 db/migrations/20251127131707_create_services.cr create mode 100644 src/actions/api/health/show.cr create mode 100644 src/actions/api/services/create.cr create mode 100644 src/actions/api/services/delete.cr create mode 100644 src/actions/api/services/index.cr create mode 100644 src/helpers/time_helper.cr create mode 100644 src/middleware/cors_handler.cr create mode 100644 src/models/service.cr create mode 100644 src/operations/delete_service.cr create mode 100644 src/operations/save_service.cr create mode 100644 src/queries/service_query.cr create mode 100644 src/serializers/service_serializer.cr rename src/{stem_temp.cr => stem.cr} (100%) diff --git a/db/migrations/20251127131707_create_services.cr b/db/migrations/20251127131707_create_services.cr new file mode 100644 index 0000000..fa59089 --- /dev/null +++ b/db/migrations/20251127131707_create_services.cr @@ -0,0 +1,26 @@ +class CreateServices::V20251127131707 < Avram::Migrator::Migration::V1 + def migrate + create table_for(Service) do + primary_key id : Int64 + + add name : String + add kind : String + add url : String? + + add status : String, default: "ok" + add avg_response_ms : Int32, default: 0 + add last_check : Time? + + add check_interval_seconds : Int32, default: 60 + add timeout_seconds : Int32, default: 30 + + add_timestamps + + add_index :name, unique: true + end + end + + def rollback + drop table_for(Service) + end +end diff --git a/src/actions/api/health/show.cr b/src/actions/api/health/show.cr new file mode 100644 index 0000000..b632608 --- /dev/null +++ b/src/actions/api/health/show.cr @@ -0,0 +1,5 @@ +class Api::Health::Show < ApiAction + get "/api/health" do + json({ok: true, version: "0.1.0"}) + end +end diff --git a/src/actions/api/services/create.cr b/src/actions/api/services/create.cr new file mode 100644 index 0000000..e540de3 --- /dev/null +++ b/src/actions/api/services/create.cr @@ -0,0 +1,21 @@ +class Api::Services::Create < ApiAction + post "/api/services" do + data = params.from_json + + op = Service::SaveOperation.new + op.name.value = data["name"].as_s + op.kind.value = data["type"]?.try(&.as_s) || "Generic" + op.url.value = data["url"]?.try(&.as_s) + op.status.value = "ok" + op.avg_response_ms.value = 0 + op.last_check.value = Time.utc + op.check_interval_seconds.value = data["checkInterval"]?.try(&.as_i) || 60 + op.timeout_seconds.value = data["timeout"]?.try(&.as_i) || 30 + + if op.save + head 201 + else + json({errors: op.errors}, 422) + end + end +end diff --git a/src/actions/api/services/delete.cr b/src/actions/api/services/delete.cr new file mode 100644 index 0000000..fd55d04 --- /dev/null +++ b/src/actions/api/services/delete.cr @@ -0,0 +1,11 @@ +class Api::Services::Delete < ApiAction + delete "/api/services/:id" do + id = params.get(:id) + if service = ServiceQuery.new.id(id).first? + Service::DeleteOperation.delete!(service) + head 204 + else + head 404 + end + end +end diff --git a/src/actions/api/services/index.cr b/src/actions/api/services/index.cr new file mode 100644 index 0000000..5e67210 --- /dev/null +++ b/src/actions/api/services/index.cr @@ -0,0 +1,8 @@ +class Api::Services::Index < ApiAction + include Api::Auth::SkipRequireAuthToken + + get "/api/services" do + services = ServiceQuery.new.id.asc_order + json services.map { |s| ServiceSerializer.new(s).to_json } + end +end diff --git a/src/actions/api_action.cr b/src/actions/api_action.cr index a16fd09..c410281 100644 --- a/src/actions/api_action.cr +++ b/src/actions/api_action.cr @@ -4,6 +4,7 @@ abstract class ApiAction < Lucky::Action # Remove this line if you want to send cookies in the response header. disable_cookies accepted_formats [:json] + default_format :json include Api::Auth::Helpers diff --git a/src/app.cr b/src/app.cr index 21fd1ea..3081226 100644 --- a/src/app.cr +++ b/src/app.cr @@ -14,6 +14,8 @@ require "./serializers/base_serializer" require "./serializers/**" require "./emails/base_email" require "./emails/**" +require "./middleware/**" +require "./helpers/**" require "./actions/mixins/**" require "./actions/**" require "../db/migrations/**" diff --git a/src/app_server.cr b/src/app_server.cr index 53f483d..faad414 100644 --- a/src/app_server.cr +++ b/src/app_server.cr @@ -15,6 +15,7 @@ class AppServer < Lucky::BaseAppServer # Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"), # Lucky::StaticFileHandler.new("./public", fallthrough: false, directory_listing: false), Lucky::RouteNotFoundHandler.new, + CorsHandler.new, ] of HTTP::Handler end diff --git a/src/helpers/time_helper.cr b/src/helpers/time_helper.cr new file mode 100644 index 0000000..7f951df --- /dev/null +++ b/src/helpers/time_helper.cr @@ -0,0 +1,13 @@ +module TimeHelper + def self.time_ago(ts : Time?) : String + return "—" unless ts + diff = Time.utc - ts + mins = diff.total_minutes.to_i + return "#{mins} min ago" if mins < 60 + hours = diff.total_hours.to_i + return "#{hours} hour ago" if hours == 1 + return "#{hours} hours ago" if hours < 24 + days = diff.total_days.to_i + days == 1 ? "1 day ago" : "#{days} days ago" + end +end diff --git a/src/middleware/cors_handler.cr b/src/middleware/cors_handler.cr new file mode 100644 index 0000000..cfd8bcc --- /dev/null +++ b/src/middleware/cors_handler.cr @@ -0,0 +1,17 @@ +class CorsHandler + include HTTP::Handler + + def call(context : HTTP::Server::Context) + res = context.response + res.headers["Access-Control-Allow-Origin"] = ENV["CORS_ORIGIN"]? || "*" + res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + + if context.request.method == "OPTIONS" + res.status_code = 204 + return + end + + call_next(context) + end +end diff --git a/src/models/service.cr b/src/models/service.cr new file mode 100644 index 0000000..89ba77a --- /dev/null +++ b/src/models/service.cr @@ -0,0 +1,12 @@ +class Service < BaseModel + table do + column name : String + column kind : String + column url : String? + column status : String + column avg_response_ms : Int32 + column last_check : Time? + column check_interval_seconds : Int32 + column timeout_seconds : Int32 + end +end diff --git a/src/operations/delete_service.cr b/src/operations/delete_service.cr new file mode 100644 index 0000000..1f43d93 --- /dev/null +++ b/src/operations/delete_service.cr @@ -0,0 +1,4 @@ +class DeleteService < Service::DeleteOperation + # Read more on deleting records + # https://luckyframework.org/guides/database/deleting-records +end diff --git a/src/operations/save_service.cr b/src/operations/save_service.cr new file mode 100644 index 0000000..c296781 --- /dev/null +++ b/src/operations/save_service.cr @@ -0,0 +1,6 @@ +class SaveService < Service::SaveOperation + # To save user provided params to the database, you must permit them + # https://luckyframework.org/guides/database/saving-records#perma-permitting-columns + # + # permit_columns name, kind, url, status, avg_response_ms, last_check, check_interval_seconds, timeout_seconds +end diff --git a/src/queries/service_query.cr b/src/queries/service_query.cr new file mode 100644 index 0000000..2d83c6f --- /dev/null +++ b/src/queries/service_query.cr @@ -0,0 +1,2 @@ +class ServiceQuery < Service::BaseQuery +end diff --git a/src/serializers/service_serializer.cr b/src/serializers/service_serializer.cr new file mode 100644 index 0000000..9355be6 --- /dev/null +++ b/src/serializers/service_serializer.cr @@ -0,0 +1,15 @@ +class ServiceSerializer < BaseSerializer + def initialize(@service : Service); end + + def render + { + id: @service.id, + name: @service.name, + type: @service.kind, + status: @service.status, + responseTime: @service.avg_response_ms, + lastCheck: TimeHelper.time_ago(@service.last_check), + sparkline: [] of Int32, + } + end +end diff --git a/src/stem_temp.cr b/src/stem.cr similarity index 100% rename from src/stem_temp.cr rename to src/stem.cr diff --git a/tasks/db/seed/sample_data.cr b/tasks/db/seed/sample_data.cr index 231d7e8..ddb3f14 100644 --- a/tasks/db/seed/sample_data.cr +++ b/tasks/db/seed/sample_data.cr @@ -8,23 +8,32 @@ class Db::Seed::SampleData < LuckyTask::Task summary "Add sample database records helpful for development" def call - # Using an Avram::Factory: - # - # Use the defaults, but override just the email - # UserFactory.create &.email("me@example.com") + return if ServiceQuery.new.select_count > 0 + + now = Time.utc + + services = [ + {name: "rails-api", kind: "Rails", url: "https://api.example.com", status: "ok", avg: 145, last_check_min: 2}, + {name: "node-worker", kind: "Node.js", url: "https://worker.example.com", status: "ok", avg: 89, last_check_min: 1}, + {name: "auth-service", kind: "Rails", url: "https://auth.example.com", status: "warning", avg: 312, last_check_min: 3}, + {name: "websocket-server", kind: "Node.js", url: "wss://ws.example.com", status: "ok", avg: 52, last_check_min: 1}, + {name: "payment-gateway", kind: "Rails", url: "https://pay.example.com", status: "ok", avg: 198, last_check_min: 2}, + {name: "email-processor", kind: "Node.js", url: "https://mail.example.com", status: "error", avg: 0, last_check_min: 5}, + ] + + services.each do |s| + op = Service::SaveOperation.new + op.name.value = s[:name] + op.kind.value = s[:kind] + op.url.value = s[:url] + op.status.value = s[:status] + op.avg_response_ms.value = s[:avg] + op.last_check.value = now - s[:last_check_min].minutes + op.check_interval_seconds.value = 60 + op.timeout_seconds.value = 30 + op.save! + end - # Using a SaveOperation: - # ``` - # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123") - # ``` - # - # You likely want to be able to run this file more than once. To do that, - # only create the record if it doesn't exist yet: - # ``` - # if UserQuery.new.email("me@example.com").none? - # SignUpUser.create!(email: "me@example.com", password: "test123", password_confirmation: "test123") - # end - # ``` puts "Done adding sample data" end end From f1fc27d0edc3116890f254222d722aa82e8761bf Mon Sep 17 00:00:00 2001 From: unurgunite Date: Fri, 28 Nov 2025 00:31:48 +0300 Subject: [PATCH 5/6] Updated README.md --- README.md | 370 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 356 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index db7b6c6..c12ff39 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,365 @@ -# stem +# Stem -This is a project written using [Lucky](https://luckyframework.org). Enjoy! +[![License](https://img.shields.io/github/license/FlorexLabs/stem?color=0aa387)](LICENSE) +[![Issues](https://img.shields.io/github/issues/FlorexLabs/stem)](https://github.com/FlorexLabs/stem/issues) +[![Pull Requests](https://img.shields.io/github/issues-pr/FlorexLabs/stem)](https://github.com/FlorexLabs/stem/pulls) +[![Last Commit](https://img.shields.io/github/last-commit/FlorexLabs/stem?color=14b8a6)](https://github.com/FlorexLabs/stem/commits/main) +[![Crystal](https://img.shields.io/badge/Crystal-1.18+-000000.svg)](https://crystal-lang.org/) +[![Lucky](https://img.shields.io/badge/Lucky-API%20app-ff4081.svg)](https://luckyframework.org/) -### Setting up the project +--- -1. [Install required dependencies](https://luckyframework.org/guides/getting-started/installing#install-required-dependencies) -1. Update database settings in `config/database.cr` -1. Run `script/setup` -1. Run `lucky dev` to start the app +> [!WARNING] +> Stem is a **work in progress** and is not yet ready for production use. Stay for future updates! -### Using Docker for development +> **Stem** is the backend API for the [Florex](https://github.com/FlorexLabs) +> stack ([Stem](https://github.com/FlorexLabs/stem) + [Bloom](https://github.com/FlorexLabs/bloom) + Seed + Root). +> It's a **Lucky (Crystal) API‑only app** that exposes services, health, and auth endpoints for the Bloom dashboard. -1. [Install Docker](https://docs.docker.com/engine/install/) -1. Run `docker compose up` +--- -The Docker container will boot all of the necessary components needed to run your Lucky application. -To configure the container, update the `docker-compose.yml` file, and the `docker/development.dockerfile` file. +* [Stem](#stem) + * [Features](#features) + * [Stack Overview](#stack-overview) + * [Endpoints Overview](#endpoints-overview) + * [Quick Start (local)](#quick-start-local) + * [Database & Migrations](#database--migrations) + * [Seed Data](#seed-data) + * [Docker Development](#docker-development) + * [Environment Variables](#environment-variables) + * [Project Structure](#project-structure) + * [Available Tasks](#available-tasks) + * [License](#license) +## Features -### Learning Lucky +- ✅ **API‑only Lucky application** +- ✅ JSON endpoints for: + - `/api/health` – health check + - `/api/services` – list/create/delete monitored services + - `/api/sign_ins`, `/api/sign_ups` – token‑based auth (JWT) + - `/api/me` – current user info (when auth is enabled) +- ✅ **PostgreSQL** via **Avram** (Lucky's ORM + migrator) +- ✅ Optional demo seed data for services +- ✅ CORS middleware for cross‑origin access from Bloom (Vue dashboard) +- ✅ Ready to containerize with `docker compose` -Lucky uses the [Crystal](https://crystal-lang.org) programming language. You can learn about Lucky from the [Lucky Guides](https://luckyframework.org/guides/getting-started/why-lucky). +--- + +## Stack Overview + +| Tool | Purpose | +|--------------------|--------------------------------------| +| **Crystal 1.18+** | Language | +| **Lucky** | Web framework (API‑only) | +| **Avram** | ORM + migrations | +| **Authentic** | Password hashing/auth | +| **JWT** | Token‑based authentication | +| **PostgreSQL** | Primary database | +| **docker compose** | Optional dev environment (Stem + DB) | + +--- + +## Endpoints Overview + +Current main endpoints (all return JSON): + +- `GET /api/health` + Simple health check: `{ "ok": true, "version": "0.1.0" }`. + +- `GET /api/services` + List services: + ```json + [ + { + "id": 1, + "name": "rails-api", + "type": "Rails", + "status": "ok", + "responseTime": 145, + "lastCheck": "2 min ago", + "sparkline": [] + }, + ... + ] + ``` + +- `POST /api/services` + Create a new service. Example body: + ```json + { + "name": "my-service", + "type": "Node.js", + "url": "https://service.example.com", + "checkInterval": 60, + "timeout": 30 + } + ``` + +- `DELETE /api/services/:id` + Delete a service by ID. + +- `POST /api/sign_ups` + Create a user and return a JWT: + ```json + { + "user": { + "email": "me@example.com", + "password": "password", + "password_confirmation": "password" + } + } + ``` + +- `POST /api/sign_ins` + Sign in and get a JWT. + +- `GET /api/me` + Return the current user's basic info when provided with a valid `Authorization: Bearer ` header. + +Depending on configuration, authentication can be globally required or selectively skipped per action. + +--- + +## Quick Start (local) + +Requirements: + +- Crystal ≥ 1.18.x +- PostgreSQL ≥ 18.x + +1. Clone and install: + +```bash +git clone https://github.com/FlorexLabs/stem.git +cd stem + +shards install +``` + +2. Configure database for local dev + +Edit `.env` (already present) to match your local Postgres: + +```env +LUCKY_ENV=development +PORT=3000 + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD=postgres +DB_NAME=florex + +SECRET_KEY_BASE= +``` + +3. Create and migrate DB: + +```bash +lucky db.create +lucky db.migrate +``` + +4. (Optional) Seed demo data: + +```bash +lucky db.seed +# or for just sample data: +lucky db.seed.sample_data +``` + +5. Run the dev server: + +```bash +lucky dev +``` + +The API will be available at `http://localhost:3000`. + +Test: + +```bash +curl http://localhost:3000/api/health +curl http://localhost:3000/api/services +``` + +--- + +## Database & Migrations + +Stem uses **Avram** for migrations. + +- Migrations live in `db/migrations/`: + - `00000000000001_create_users.cr` + - `2025XXXXXXXXXX_create_services.cr` + - etc. + +Create a new model + migration: + +```bash +lucky gen.model ServiceMetric \ + service : Service \ + response_ms : Int32 \ + status : String \ + ts : Time +``` + +Run migrations: + +```bash +lucky db.migrate +``` + +In development, the server also calls: + +```crystal +Avram::Migrator::Runner.new.ensure_migrated! +Avram::SchemaEnforcer.ensure_correct_column_mappings! +``` + +on startup (`src/start_server.cr`), to keep schema and models in sync. + +--- + +## Seed Data + +Two seed tasks are available: + +- `tasks/db/seed/required_data.cr` + For **required** baseline data (currently minimal). + +- `tasks/db/seed/sample_data.cr` + Seeds example `Service` records used by the Bloom dashboard mock: + + ```bash + lucky db.seed.sample_data + ``` + +Run all seed tasks: + +```bash +lucky db.seed +``` + +--- + +## Docker Development + +A simple dev setup is provided using `docker compose`: + +- `db` – Postgres (18.1‑alpine) +- `lucky` – Crystal + Lucky dev server + +`docker/development.dockerfile` builds a Crystal image with Lucky CLI for development, and `docker/dev_entrypoint.sh`: + +- installs shards, +- waits for Postgres, +- runs migrations if needed, +- starts `lucky dev`. + +To run everything in Docker: + +```bash +docker compose up --build +``` + +The API is then available at `http://localhost:3000`. + +You can still use `.env` for local (non‑Docker) development; the compose file sets its own DB_* env for the containers. + +--- + +## Environment Variables + +Common variables: + +| Variable | Purpose | Example | +|-------------------|---------------------------------------|-------------------------------------| +| `LUCKY_ENV` | Lucky environment (`development` etc) | `development` | +| `PORT` | HTTP port | `3000` | +| `DB_HOST` | DB host | `127.0.0.1` or `db` in Docker | +| `DB_PORT` | DB port | `5432` | +| `DB_USERNAME` | DB user | `postgres` / `lucky` | +| `DB_PASSWORD` | DB password | `postgres` / `password` | +| `DB_NAME` | DB database name | `florex` / `lucky` | +| `DATABASE_URL` | (Prod) full DB URL | `postgres://user:pass@host:5432/db` | +| `SECRET_KEY_BASE` | Lucky's secret key for JWT, etc. | output of `lucky gen.secret` | +| `CORS_ORIGIN` | Allowed origin for CORS | `http://localhost:5173` (Bloom dev) | + +In development: + +- `config/database.cr` uses `DATABASE_URL` if present; otherwise it uses `DB_*` values. +- In production (`LUCKY_ENV=production`), `DATABASE_URL` is required. + +--- + +## Project Structure + +Key files and directories: + +```text +stem/ +├─ src/ +│ ├─ stem.cr # compiled entry (requires start_server) +│ ├─ start_server.cr # starts AppServer +│ ├─ app_server.cr # Lucky::BaseAppServer + middleware (CORS, errors) +│ ├─ app.cr # requires shards, config, models, actions, etc. +│ ├─ app_database.cr # Avram::Database subclass +│ ├─ actions/ # API actions (controllers) +│ │ ├─ api/ +│ │ │ ├─ health/show.cr +│ │ │ ├─ services/*.cr +│ │ │ ├─ sign_ins/*.cr +│ │ │ ├─ sign_ups/*.cr +│ │ │ └─ me/show.cr +│ │ ├─ errors/show.cr +│ │ └─ home/index.cr +│ ├─ middleware/ # e.g. CorsHandler +│ ├─ models/ # User, Service, etc. +│ ├─ operations/ # Save/SignIn/SignUp ops +│ ├─ queries/ # Avram query objects +│ ├─ serializers/ # JSON serializers +│ └─ helpers/ # e.g. TimeHelper +├─ db/ +│ ├─ migrations/ # Avram migrations +│ └─ structure.sql # (optional) schema dump +├─ docker/ +│ ├─ development.dockerfile +│ ├─ dev_entrypoint.sh +│ └─ wait-for-it.sh +├─ compose.yml # Docker dev compose (db + lucky) +├─ .env # Local development env vars +├─ shard.yml # Shards config +├─ shards.lock +└─ README.md # this file +``` + +--- + +## Available Tasks + +Run tasks with `lucky`: + +| Command | Description | +|-----------------------------|------------------------------------| +| `lucky dev` | Start the dev server | +| `lucky db.create` | Create database | +| `lucky db.migrate` | Run migrations | +| `lucky db.rollback` | Roll back last migration | +| `lucky db.schema.dump` | Export schema to `structure.sql` | +| `lucky db.seed` | Run all seed tasks | +| `lucky db.seed.sample_data` | Seed demo services | +| `lucky gen.model` | Generate model + query + migration | +| `lucky routes` | Show defined routes | + +--- + +## License + +This project is released under the [MIT License](LICENSE). + +--- + +© 2025 Florex Labs. +Stem powers the backend of Florex so your systems can bloom. From 7fbd1433fda67530f366ecce5a5f71b0658de038 Mon Sep 17 00:00:00 2001 From: unurgunite Date: Fri, 28 Nov 2025 00:54:44 +0300 Subject: [PATCH 6/6] Format code --- README.md | 7 ++----- src/serializers/service_serializer.cr | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c12ff39..88589c9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # Stem +[![Crystal](https://img.shields.io/badge/crystal-1.18.2-000000?logo=crystal&logoColor=white)](https://crystal-lang.org/) +[![Build](https://github.com/FlorexLabs/stem/actions/workflows/ci.yml/badge.svg)](https://github.com/FlorexLabs/stem/actions/workflows/ci.yml) [![License](https://img.shields.io/github/license/FlorexLabs/stem?color=0aa387)](LICENSE) -[![Issues](https://img.shields.io/github/issues/FlorexLabs/stem)](https://github.com/FlorexLabs/stem/issues) -[![Pull Requests](https://img.shields.io/github/issues-pr/FlorexLabs/stem)](https://github.com/FlorexLabs/stem/pulls) -[![Last Commit](https://img.shields.io/github/last-commit/FlorexLabs/stem?color=14b8a6)](https://github.com/FlorexLabs/stem/commits/main) -[![Crystal](https://img.shields.io/badge/Crystal-1.18+-000000.svg)](https://crystal-lang.org/) -[![Lucky](https://img.shields.io/badge/Lucky-API%20app-ff4081.svg)](https://luckyframework.org/) --- diff --git a/src/serializers/service_serializer.cr b/src/serializers/service_serializer.cr index 9355be6..eb87e5e 100644 --- a/src/serializers/service_serializer.cr +++ b/src/serializers/service_serializer.cr @@ -9,7 +9,7 @@ class ServiceSerializer < BaseSerializer status: @service.status, responseTime: @service.avg_response_ms, lastCheck: TimeHelper.time_ago(@service.last_check), - sparkline: [] of Int32, + sparkline: [] of Int32, } end end