Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ db.sqlite
.env.*
!.env.example
default.profraw
.vscode

# Development guidance for Claude Code
CLAUDE.md
74 changes: 0 additions & 74 deletions CLAUDE.md

This file was deleted.

8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ RUN mkdir /staging
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
RUN --mount=type=cache,target=/build/.build \
swift build -c release \
--product HabitTrackerAppServer \
--product GrowBitAppServer \
--static-swift-stdlib \
-Xlinker -ljemalloc && \
# Copy main executable to staging area
cp "$(swift build -c release --show-bin-path)/HabitTrackerAppServer" /staging && \
cp "$(swift build -c release --show-bin-path)/GrowBitAppServer" /staging && \
# Copy resources bundled by SPM to staging area
find -L "$(swift build -c release --show-bin-path)" -regex '.*\.resources$' -exec cp -Ra {} /staging \;

Expand All @@ -48,7 +48,7 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; }; \
[ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; }; \
# Make it executable
chmod +x HabitTrackerAppServer; \
chmod +x GrowBitAppServer; \
# Create the vapor user and group with /vapor home directory
useradd --user-group --create-home --system --skel /dev/null --home-dir /vapor vapor

Expand All @@ -64,5 +64,5 @@ RUN chown -R vapor:vapor /vapor
USER vapor:vapor

# Start the Vapor service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./HabitTrackerAppServer"]
ENTRYPOINT ["./GrowBitAppServer"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
8 changes: 4 additions & 4 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import PackageDescription

let package = Package(
name: "HabitTrackerAppServer",
name: "GrowBitAppServer",
platforms: [
.macOS(.v13)
],
Expand All @@ -13,18 +13,18 @@ let package = Package(
.package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver", from: "2.11.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver", from: "4.8.0"),
.package(url: "https://github.com/dmakarau/HabitTrackerAppSharedDTO.git", branch: "main"),
.package(url: "https://github.com/dmakarau/GrowBitSharedDTO", branch: "main"),

// 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
],
targets: [
.executableTarget(
name: "HabitTrackerAppServer",
name: "GrowBitAppServer",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "JWT", package: "jwt"),
.product(name: "HabitTrackerAppSharedDTO", package: "HabitTrackerAppSharedDTO"),
.product(name: "GrowBitSharedDTO", package: "GrowBitSharedDTO"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
Expand All @@ -34,10 +34,11 @@ let package = Package(
swiftSettings: swiftSettings
),
.testTarget(
name: "HabitTrackerAppServerTests",
name: "GrowBitAppServerTests",
dependencies: [
.target(name: "HabitTrackerAppServer"),
.target(name: "GrowBitAppServer"),
.product(name: "VaporTesting", package: "vapor"),
.product(name: "GrowBitSharedDTO", package: "GrowBitSharedDTO"),
],
swiftSettings: swiftSettings
)
Expand Down
42 changes: 26 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# HabitTracker API Server
# GrowBit API Server

A Swift-based REST API server for habit tracking, built with the Vapor web framework.

Expand All @@ -12,7 +12,7 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame
- SQLite (development)
- **Swift Version**: 6.2+
- **Platform**: macOS 13+
- **Shared DTOs**: HabitTrackerAppSharedDTO (external package)
- **Shared DTOs**: GrowBitSharedDTO (external package)

## Features

Expand All @@ -30,6 +30,9 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame
- `POST /api/register` - User registration ✅
- `POST /api/login` - User login ✅

#### Categories
- `POST /api/categories` - Create new category ✅

### Planned Endpoints

#### Authentication
Expand All @@ -38,7 +41,6 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame

#### Categories
- `GET /api/categories` - Get all categories
- `POST /api/categories` - Create new category
- `PUT /api/categories/:id` - Update category
- `DELETE /api/categories/:id` - Delete category

Expand All @@ -65,8 +67,8 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame

1. Clone the repository:
```bash
git clone <repository-url>
cd HabitTracker-API-Server
git clone https://github.com/dmakarau/GrowBit-API-Server.git
cd GrowBit-API-Server
```

2. Resolve dependencies:
Expand All @@ -89,7 +91,7 @@ openssl rand -base64 32

#### Development Mode
```bash
swift run HabitTrackerAppServer serve --hostname 0.0.0.0 --port 8080
swift run GrowBitAppServer serve --hostname 0.0.0.0 --port 8080
```

#### Using Docker
Expand All @@ -114,27 +116,31 @@ swift test
## Project Structure

```
HabitTracker-API-Server/
GrowBit-API-Server/
├── Package.swift # Swift Package Manager configuration
├── Sources/
│ └── HabitTrackerAppServer/
│ └── GrowBitAppServer/
│ ├── entrypoint.swift # Application entry point
│ ├── configure.swift # Application configuration
│ ├── routes.swift # Route definitions
│ ├── Controllers/ # API controllers
│ │ └── UserController.swift # User registration/auth controller
│ │ ├── UserController.swift # User registration/auth controller
│ │ └── HabitsController.swift # Habits and categories controller
│ ├── Models/ # Data models
│ │ ├── User.swift # User model with validation
│ │ ├── Category.swift # Category model
│ │ └── AuthPayload.swift # JWT payload structure
│ ├── Extensions/ # Protocol conformances for shared types
│ │ ├── RegisterResponseDTO+Extensions.swift # Vapor Content conformance
│ │ └── LoginResponseDTO+Extensions.swift # Vapor Content conformance
│ │ ├── LoginResponseDTO+Extensions.swift # Vapor Content conformance
│ │ └── CategoryResponseDTO+Extensions.swift # Category DTO conformance
│ └── Migrations/ # Database migrations
│ └── CreateUsersTableMigration.swift
│ ├── CreateUsersTableMigration.swift
│ └── CreateHabitsCategoryTableMigration.swift
├── Tests/
│ └── HabitTrackerAppServerTests/
│ ├── HabitTrackerAppServerTests.swift
│ └── HabitTrackerAppServerLoginTests.swift
│ └── GrowBitAppServerTests/
│ ├── GrowBitAppServerTests.swift
│ └── GrowBitAppServerLoginTests.swift
├── Public/ # Static files directory
├── Dockerfile # Docker configuration
├── docker-compose.yml # Docker Compose configuration
Expand All @@ -147,7 +153,7 @@ Create a `.env` file in the root directory with the following variables:

```bash
# Database Configuration
DATABASE_URL=postgresql://username:password@localhost:5432/habittracker_db
DATABASE_URL=postgresql://username:password@localhost:5432/growbit_db

# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key-here
Expand Down Expand Up @@ -187,16 +193,20 @@ This project serves as a learning experience for backend development with Vapor.
- ✅ User login endpoint with JWT token generation
- ✅ Password hashing and verification
- ✅ Database migration for users table
- ✅ Category model with database migration
- ✅ Category creation endpoint with JWT authentication
- ✅ Swift 6.2 concurrency support (@Sendable)
- ✅ Shared DTO package integration with @retroactive conformance
- ✅ Test suite for authentication endpoints
- ✅ Test suite for category operations

### In Progress
- 🔄 User logout endpoint
- 🔄 Protected routes with JWT middleware

### Planned Features
- 📋 Categories and Habits CRUD operations
- 📋 Remaining Categories CRUD operations (GET, PUT, DELETE)
- 📋 Habits CRUD operations
- 📋 Habit entries and calendar functionality
- 📋 JWT token refresh endpoint

Expand Down
79 changes: 79 additions & 0 deletions Sources/GrowBitAppServer/Controllers/HabitsController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// HabitsController.swift
// GrowBitAppServer
//
// Created by Denis Makarau on 03.10.25.
//

import Foundation
import Vapor
import GrowBitSharedDTO

struct HabitsController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {

// /api/:userId
let api = routes.grouped("api", ":userId")

// POST: saving a habbit category
// /api/:userId/categories
api.post("categories", use: saveHabitCategory)
}

@Sendable func saveHabitCategory(req: Request) async throws -> CategoryResponseDTO {

// get the user id
guard let userId = req.parameters.get("userId", as: UUID.self) else {
throw Abort(.badRequest, reason: "Missing or invalid userId parameter") // HTTP 400
}

// Decode request as a simple dictionary to get name and colorCode
let requestData = try req.content.decode([String: String].self)
guard let name = requestData["name"],
let colorCode = requestData["colorCode"] else {
throw Abort(.badRequest, reason: "Missing required fields: name and colorCode") // HTTP 400
}

// Validate empty name
guard !name.isEmpty else {
throw Abort(.badRequest, reason: "Category name cannot be empty") // HTTP 400
}

// Validate color code format (#RRGGBB)
let colorCodePattern = #"^#([A-Fa-f0-9]{6})$"#
guard colorCode.range(of: colorCodePattern, options: .regularExpression) != nil else {
throw Abort(.badRequest, reason: "Color code should be in format #RRGGBB") // HTTP 400
}

// Check for duplicate category name for this user (case-insensitive)
let existingCategories = try await Category.query(on: req.db)
.filter(\.$user.$id, .equal, userId)
.all()

let existingCategory = existingCategories.first(where: { $0.name.lowercased() == name.lowercased() })

if existingCategory != nil {
throw Abort(.conflict, reason: "A category with this name already exists") // HTTP 409
}

let habitCategory = Category(
name: name,
colorCode: colorCode,
userId: userId
)
try await habitCategory.save(on: req.db)

// After saving, ensure the ID is assigned
guard habitCategory.id != nil else {
throw Abort(.internalServerError, reason: "Failed to get ID after saving category") // HTTP 500
}

// DTO for the response
guard let categoryResponseDTO = CategoryResponseDTO(habitCategory) else {
throw Abort(.internalServerError, reason: "Failed to create response DTO") // HTTP 500
}

return categoryResponseDTO
}

}
Loading