{"id":50335305,"url":"https://github.com/bdombro/swift-crud","last_synced_at":"2026-05-29T13:01:46.702Z","repository":{"id":357658896,"uuid":"1236764940","full_name":"bdombro/swift-crud","owner":"bdombro","description":"A lightweight CRUD API server built with Swift","archived":false,"fork":false,"pushed_at":"2026-05-23T13:46:32.000Z","size":160,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-23T15:25:13.190Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bdombro.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-12T14:51:17.000Z","updated_at":"2026-05-23T13:46:36.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bdombro/swift-crud","commit_stats":null,"previous_names":["bdombro/swift-crud"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/bdombro/swift-crud","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bdombro%2Fswift-crud","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bdombro%2Fswift-crud/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bdombro%2Fswift-crud/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bdombro%2Fswift-crud/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bdombro","download_url":"https://codeload.github.com/bdombro/swift-crud/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bdombro%2Fswift-crud/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33652986,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T13:01:45.463Z","updated_at":"2026-05-29T13:01:46.695Z","avatar_url":"https://github.com/bdombro.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"![Logo](logo.png)\n\u003c!-- Big money NE - https://patorjk.com/software/taag/#p=testall\u0026f=Bulbhead\u0026t=swift-crud\u0026x=none\u0026v=4\u0026h=4\u0026w=80\u0026we=false\u003e --\u003e\n\nA lightweight, exceptionally fast CRUD API server built with Swift, [SwiftNIO](https://github.com/apple/swift-nio) (HTTP), and [Blackbird](https://github.com/marcoarment/Blackbird) (SQLite).\n\n\u003e **Philosophy:** Intentionally frugal with dependencies. No heavy frameworks like Vapor or Hummingbird — we only pull in a lightweight HTTP server and a zero-config ORM, each chosen for high value with minimal overhead.\n\n---\n\n## Performance\n\nThe results of the benchmarks in the justfile were observed on a MacBook M4 Pro in production mode:\n\n- **`benchmark-healthz`**: Authenticated GET request to `/healthz` achieves **63+k req/s**.\n- **`benchmark-cookie`**: Authenticated GET request to `/api/session` achieves **56+k req/s**.\n- **`benchmark-r`**: Authenticated single database read (GET `/api/posts?limit=1`) achieves **56k req/s**.\n- **`benchmark-w`**: Authenticated single database write (POST `/api/posts`) achieves **14-26k req/s**.\n- **`benchmark-rw`**: Mixed workload (90% reads, 10% writes) achieves **45k req/s total**:\n  - Reads: **36k req/s**\n  - Writes: **10k req/s**\n\nComparing the raw req/s (as in no db read/write) speed between languages:\n\nRust - 85k\nSwift - 63k\nGo - 45k - 70k\nJava - 40k - 80k\nNode.js - 15k - 30k\nPython - 1k - 4k\n\n\u003e NOTE: DB read/write speed dwarfs language speed; BUT language DB libs are also often majorly under-optimized\n\n## Quickstart\n\n```bash\n# Prerequisites\nbrew install just\n\n# Build\njust build\n\n# Run (listens on port 8222 by default)\njust run\n\n# Test\njust test\n```\n\nServer starts on `http://127.0.0.1:8222` by default.\n\n### Configuration\n\nThe server is configured via environment variables:\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `PORT` | `8222` | HTTP server port |\n| `DB_PATH` | `db.sqlite` | SQLite database file path |\n| `DB_DEBUG` | — | Set to `true` or `1` to log every SQL query |\n| `AUTH_SECRET` | — | HMAC signing key for the `user_id` cookie. Set in production to prevent cookie forgery |\n| `COOKIE_DOMAIN` | — | Parent domain for the session cookie (e.g. `toodyapp.com`) so `api.*` and `app.*` subdomains share auth. Omit for host-only cookies (local dev) |\n| `COOKIE_SECURE` | `true` | Set to `false` or `0` to omit `Secure` on session cookies (local HTTP testing only) |\n| `CORS_ALLOWED_ORIGINS` | — | Comma-separated browser origins allowed for credentialed CORS (e.g. `https://toodyapp.com`) |\n| `SMTP_HOST` | — | SMTP server hostname (omit to fall back to print-to-stdout) |\n| `SMTP_PORT` | `587` | SMTP server port |\n| `SMTP_USERNAME` | — | SMTP username |\n| `SMTP_PASSWORD` | — | SMTP password |\n| `SMTP_FROM` | — | \"From\" address for outgoing emails |\n| `SMTP_FROM_NAME` | — | Optional display name shown in the `From` header (e.g. `BTEC`) |\n| `SMTP_TIMEOUT_SECONDS` | `30` | TCP connect and per-command SMTP read timeout |\n| `SMTP_TLS_SERVERNAME` | — | TLS SNI / cert hostname when `SMTP_HOST` is an MX name (e.g. `_dc-mx.*`) or does not match the mail cert |\n\nExample with email sending:\n\n```bash\nPORT=9000 SMTP_HOST=smtp.example.com SMTP_USERNAME=user@example.com \\\n  SMTP_PASSWORD=secret SMTP_FROM=noreply@example.com just run\n```\n\n## API Reference\n\nAll request and response bodies use `Content-Type: application/json`.  \nAuth is handled via a session cookie set on login.\n\n### Authentication \u0026 Sessions\n\n#### `POST /api/session/send-code`\n\nRequest a one-time login code by email.\n\n**Request body:**\n```json\n{\n  \"email\": \"user@example.com\"\n}\n```\n\n**Responses:**\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Code sent — either via SMTP or printed to server logs (see [Configuration](#configuration)) |\n| `429` | Rate-limited — wait 2 minutes between requests |\n| `401` | Invalid email (missing `@`) |\n\n**Notes:**\n- New emails get a user record created automatically.\n- Existing users get their code hash and attempt count reset.\n- Codes are **8 decimal digits**, zero-padded (e.g. `00428173`). Shorter numeric input on login is left-padded to 8 digits.\n- By default the code is printed to stdout (`Email send simulated: to= ..., code= ...`). Set `SMTP_HOST` and related env vars to deliver via email instead.\n\n---\n\n#### `POST /api/session/login`\n\nExchange a code for a session cookie.\n\n**Request body:**\n```json\n{\n  \"email\": \"user@example.com\",\n  \"code\": \"00000000\"\n}\n```\n\n**Responses:**\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Authenticated — `Set-Cookie: user_id=\u003cid\u003e.\u003csig\u003e` header returned |\n| `401` | Invalid email, code, or code expired (\u003e10 min / max 3 attempts) |\n\n**Notes:**\n- `code` must be numeric (up to 8 digits); values shorter than 8 digits are zero-padded before verification.\n- On success the server sets `user_id=\u003cid\u003e.\u003csig\u003e` with `Path=/`, `HttpOnly`, and (by default) `Secure` and `SameSite=Lax`. Set `COOKIE_DOMAIN` for cross-subdomain sharing; set `CORS_ALLOWED_ORIGINS` and use credentialed requests from the frontend when the API and app are on different origins.\n- Codes expire after 10 minutes.\n- After 3 failed attempts the code is invalidated.\n- Successful login clears the code hash and attempt data from the user record.\n- The cookie is HMAC-signed when `AUTH_SECRET` is configured (see [Configuration](#configuration)). Without it, the cookie is unsigned — suitable only for local dev.\n\n---\n\n#### `POST /api/session/logout`\n\nClear the session cookie.\n\n**Request headers:** (none required)\n\n**Response:**\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Cookie cleared — `Set-Cookie: user_id=; Expires=...` |\n\nReturns an expired `user_id` cookie to clear the client-side value.\n\n---\n\n#### `GET /api/session`\n\nValidate the session cookie and return the authenticated user's id.\n\n**Cookies:** `user_id=\u003cid\u003e.\u003csig\u003e`\n\n**Response `200`:**\n\n```json\n1\n```\n\nThe body is a JSON number (the user's primary key), not a user object. Clients such as `@slimr/dbsync`'s `RestAdapter` only need the status code (`200` = logged in).\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Authenticated — body is the numeric `userId` |\n| `401` | Missing or invalid session cookie |\n\n---\n\n### Posts\n\nAll post endpoints require authentication (`user_id` cookie). Post IDs are arbitrary strings (UUIDs recommended).\n\n#### `GET /api/posts`\n\nList the authenticated user's posts, newest first.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Query parameters:**\n\n| Param | Type | Default | Description |\n|-------|------|---------|-------------|\n| `limit` | int | 10 | Max items (capped at 1000) |\n| `after` | Unix ms | — | Return posts with `updatedAt \u003e` this cursor (epoch milliseconds) |\n\n**Response `200`:**\n```json\n{\n  \"items\": [\n    {\n      \"id\": \"a1b2c3d4\",\n      \"content\": \"Hello world\",\n      \"createdAt\": 1747008000000,\n      \"updatedAt\": 1747008000000,\n      \"userId\": 1,\n      \"variant\": \"note\",\n      \"isDeleted\": false\n    }\n  ],\n  \"hasMore\": false\n}\n```\n\n`hasMore` is `true` when there are additional pages beyond the returned set. Use the last item's `updatedAt` (Unix epoch **milliseconds**, JSON number) as the `after` query parameter for the next page.\n\n| Status | Meaning |\n|--------|---------|\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `POST /api/posts`\n\nCreate a new post.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Request body:**\n```json\n{\n  \"id\": \"my-unique-id\",\n  \"content\": \"Hello world\",\n  \"variant\": \"note\",\n  \"createdAt\": 1747008000000,\n  \"updatedAt\": 1747008000000\n}\n```\n\nAll fields are optional except `content` and `variant`. If `id` is omitted a UUID is generated. If `createdAt` / `updatedAt` are omitted the current server time is used. Duplicate `id` values perform an upsert.\n\n**Response `201`:**\n```json\n{ \"message\": \"success\" }\n```\n\n| Status | Meaning |\n|--------|---------|\n| `201` | Created (or upserted) |\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `GET /api/posts/:id`\n\nFetch a single post by ID. Scoped to the authenticated user.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Response `200`:**\n```json\n{\n  \"id\": \"a1b2c3d4\",\n  \"content\": \"Hello world\",\n  \"createdAt\": 1747008000000,\n  \"updatedAt\": 1747008000000,\n  \"userId\": 1,\n  \"variant\": \"note\"\n}\n```\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Post found |\n| `404` | Post not found or access denied |\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `PUT /api/posts/:id`\n\nUpdate a post. The request body must include a `updatedAt` timestamp that is **greater than** the currently stored `updatedAt` — this provides a last-write-wins conflict resolution.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Request body:**\n```json\n{\n  \"content\": \"Updated content\",\n  \"updatedAt\": 1747011600000\n}\n```\n\n**Response `200`:**\n```json\n{ \"message\": \"success\" }\n```\n\nIf the supplied `updatedAt` is not newer than the stored value, the update is rejected.\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Updated |\n| `404` | Post not found or supplied `updatedAt` is stale |\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `DELETE /api/posts/:id`\n\nDelete a single post by ID. Scoped to the authenticated user.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Response `200`:**\n```json\n{ \"message\": \"success\" }\n```\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Deleted (even if the post didn't exist) |\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `DELETE /api/posts`\n\nDelete **all** posts belonging to the authenticated user.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Response `200`:**\n```json\n{ \"message\": \"success\" }\n```\n\n| Status | Meaning |\n|--------|---------|\n| `200` | All posts deleted |\n| `401` | Missing or invalid session cookie |\n\n---\n\n#### `POST /api/posts/upsert-many`\n\nBulk upsert multiple posts in a single transaction. Accepts an array of post objects. All updates are scoped to the authenticated user. `createdAt` and `updatedAt` are optional per item; omitted values use the server time at upsert.\n\n**Cookies:** `user_id=\u003cid\u003e`\n\n**Request body:**\n```json\n[\n  {\n    \"id\": \"post-1\",\n    \"content\": \"First\",\n    \"variant\": \"note\",\n    \"createdAt\": 1747008000000,\n    \"updatedAt\": 1747008000000\n  },\n  {\n    \"id\": \"post-2\",\n    \"content\": \"Second\",\n    \"variant\": \"note\",\n    \"isDeleted\": false\n  }\n]\n```\n\n**Response `200`:**\n```json\n{ \"message\": \"success\" }\n```\n\n| Status | Meaning |\n|--------|---------|\n| `200` | All posts upserted |\n| `401` | Missing or invalid session cookie |\n\n---\n\n### Error responses\n\nErrors return JSON with a human-readable `message` and a stable three-digit application `code` (not the HTTP status):\n\n```json\n{ \"message\": \"invalid email\", \"code\": 104 }\n```\n\n| Code | Meaning |\n|------|---------|\n| `101` | Unauthorized (missing or invalid session) |\n| `102` | Invalid login code encoding |\n| `103` | Invalid email or login code |\n| `104` | Invalid email (send-code) |\n| `105`–`107` | Send-code rate limits / cooldown |\n| `201`–`208` | Post validation, not found, or bulk id errors |\n| `301` | Database unavailable (health check) |\n| `901` | Unknown route |\n| `902` | Request body too large |\n| `903` | Internal server error |\n\n### Common HTTP status codes\n\n| Status | Meaning |\n|--------|---------|\n| `200` | Success |\n| `201` | Created |\n| `404` | Not found |\n| `429` | Too many requests (rate-limited) |\n| `401` | Unauthorized |\n\n---\n\n## Architecture\n\nThis app is intentionally frugal with dependencies and only adds them when it's a big win:\n\n- **[SwiftNIO](https://github.com/apple/swift-nio)** — high-performance asynchronous event-driven network application framework.\n- **[Blackbird](https://github.com/marcoarment/Blackbird)** — SQLite ORM with zero-config schema migrations.\n\n**File layout:**\n\n```\nSources/swift-crud/\n├── main.swift              # App entrypoint: DB setup, server launch\n├── Routes.swift            # Central route registration\n├── Core/\n│   ├── Server.swift        # NIO HTTP server lifecycle\n│   ├── AccessLogger.swift  # One-line access logs (stdout / LOG_FILE)\n│   ├── APIRouter.swift     # Route table (get / post / put / delete)\n│   ├── EmailSender.swift   # Email protocol + print fallback + factory\n│   ├── SMTPEmailSender.swift # NIO SMTP client (STARTTLS / TLS)\n│   ├── Environment.swift   # Env + .env loading\n│   ├── Globals.swift       # Module singletons (db, auth secret, cookie/CORS, email)\n│   ├── SessionCookie.swift # Set-Cookie assembly for session auth\n│   ├── CORS.swift          # Credentialed CORS for allowed frontend origins\n│   ├── APIError.swift      # Three-digit error codes and JSON error bodies\n│   ├── HTTPLimits.swift    # Request body / content size caps\n│   ├── HTTPRequest.swift   # Request type + query parsing + handler typealias\n│   └── HTTPResponse.swift  # Response type + JSON helper\n├── Security/\n│   └── AuthCookie.swift    # HMAC-signed session cookie helpers\n├── Handlers/\n│   ├── Session.swift       # Auth endpoints (send-code, login, logout, session)\n│   ├── Posts.swift         # Post CRUD endpoints\n│   └── Health.swift        # GET /healthz\n└── Model/\n    ├── User.swift          # User Blackbird model\n    └── Post.swift          # Post Blackbird model\n```\n\n## Contributing\n\nSee [`CONTRIBUTING.md`](CONTRIBUTING.md). Security: [`SECURITY.md`](SECURITY.md). Code of conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).\n\n---\n\n## License\n\nMIT — see [`LICENSE`](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbdombro%2Fswift-crud","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbdombro%2Fswift-crud","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbdombro%2Fswift-crud/lists"}