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 new file mode 100644 index 0000000..391721a --- /dev/null +++ b/.env.sample @@ -0,0 +1,21 @@ +# Lucky env +LUCKY_ENV=development +PORT=3000 + +# 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..5fafaef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: stem 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 942d66d..b03bacb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ /bin/ /.shards/ *.dwarf -.idea/ +start_server +*.dwarf +*.local.cr +.env +/tmp +.idea 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..88589c9 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,362 @@ -# stem +# Stem -TODO: Write a description here +[![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) -## Installation +--- -TODO: Write installation instructions here +> [!WARNING] +> Stem is a **work in progress** and is not yet ready for production use. Stay for future updates! -## Usage +> **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. -TODO: Write usage instructions here +--- -## Development +* [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) -TODO: Write development instructions here +## Features -## Contributing +- ✅ **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` -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 +## Stack Overview -- [your-name-here](https://github.com/your-github-user) - creator and maintainer +| 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. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2b5d5b1 --- /dev/null +++ b/compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: postgres:18.1-alpine + environment: + POSTGRES_USER: ${DB_USERNAME:-lucky} + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + POSTGRES_DB: ${DB_NAME:-lucky} + volumes: + - db:/var/lib/postgresql/data + ports: + - "${DB_PORT:-5432}:5432" + + 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 + environment: + DB_HOST: db + ports: + - "3000:3000" + +volumes: + 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..6ac435e --- /dev/null +++ b/config/cookies.cr @@ -0,0 +1,25 @@ +require "./server" + +Lucky::Session.configure do |settings| + settings.key = "_stem_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/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/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 4f3e149..1a65ead 100644 --- a/shard.lock +++ b/shard.lock @@ -1,2 +1,102 @@ version: 2.0 -shards: {} +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 + + 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 + + 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 + + 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 a69d928..da4ca40 100644 --- a/shard.yml +++ b/shard.yml @@ -1,13 +1,33 @@ +--- name: stem version: 0.1.0 - -authors: - - your-name-here - targets: stem: main: src/stem.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/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/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/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/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..c410281 --- /dev/null +++ b/src/actions/api_action.cr @@ -0,0 +1,18 @@ +# 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] + default_format :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..3081226 --- /dev/null +++ b/src/app.cr @@ -0,0 +1,22 @@ +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 "./middleware/**" +require "./helpers/**" +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..faad414 --- /dev/null +++ b/src/app_server.cr @@ -0,0 +1,29 @@ +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, + CorsHandler.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/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/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/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/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/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/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/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/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/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/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/service_serializer.cr b/src/serializers/service_serializer.cr new file mode 100644 index 0000000..eb87e5e --- /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/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 index d21db5f..68e1a8d 100644 --- a/src/stem.cr +++ b/src/stem.cr @@ -1,6 +1,6 @@ -# TODO: Write documentation for `Stem` -module Stem - VERSION = "0.1.0" - - # TODO: Put your code here -end +# 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..ddb3f14 --- /dev/null +++ b/tasks/db/seed/sample_data.cr @@ -0,0 +1,39 @@ +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 + 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 + + puts "Done adding sample data" + end +end