Browse Source

Create workout creation route. Create docs

Lee Morgan 1 month ago
parent
commit
1b204de7c1

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@
 *swp
 *swo
 .env
+redoc-static.html

+ 7 - 0
docs/components/schemas/ErrorResponse.yaml

@@ -0,0 +1,7 @@
+type: object
+required:
+  - error
+properties:
+  error:
+    type: string
+    example: An error occurred

+ 26 - 0
docs/components/schemas/Exercise.yaml

@@ -0,0 +1,26 @@
+type: object
+required:
+  - id
+  - workout
+  - name
+  - exercise_type
+properties:
+  id:
+    type: string
+    format: uuid
+    description: UUID v7 primary key.
+    example: 0191a23b-dead-7000-beef-000000000003
+  workout:
+    type: string
+    format: uuid
+    description: Foreign key referencing the parent workout.
+    example: 0191a23b-dead-7000-beef-000000000002
+  name:
+    type: string
+    example: Bench Press
+  notes:
+    type: string
+    nullable: true
+    example: Keep elbows tucked
+  exercise_type:
+    $ref: './ExerciseType.yaml'

+ 7 - 0
docs/components/schemas/ExerciseType.yaml

@@ -0,0 +1,7 @@
+type: string
+enum:
+  - WeightedReps
+  - BodyweightReps
+  - Cardio
+  - TimedCardio
+example: WeightedReps

+ 7 - 0
docs/components/schemas/SuccessResponse.yaml

@@ -0,0 +1,7 @@
+type: object
+required:
+  - success
+properties:
+  success:
+    type: boolean
+    example: true

+ 29 - 0
docs/components/schemas/User.yaml

@@ -0,0 +1,29 @@
+type: object
+required:
+  - id
+  - uuid_key
+  - email
+  - name
+  - created_at
+properties:
+  id:
+    type: string
+    format: uuid
+    description: UUID v7 primary key.
+    example: 0191a23b-dead-7000-beef-000000000001
+  uuid_key:
+    type: string
+    format: uuid
+    description: UUID v4 used as a secondary token in the JWT.
+    example: 550e8400-e29b-41d4-a716-446655440000
+  email:
+    type: string
+    format: email
+    example: lee@example.com
+  name:
+    type: string
+    example: Lee
+  created_at:
+    type: string
+    format: date-time
+    example: '2026-01-01T00:00:00Z'

+ 13 - 0
docs/components/schemas/Workout.yaml

@@ -0,0 +1,13 @@
+type: object
+required:
+  - id
+  - name
+properties:
+  id:
+    type: string
+    format: uuid
+    description: UUID v7 primary key.
+    example: 0191a23b-dead-7000-beef-000000000002
+  name:
+    type: string
+    example: Push Day

+ 5 - 0
docs/components/securitySchemes.yaml

@@ -0,0 +1,5 @@
+cookieAuth:
+  type: apiKey
+  in: cookie
+  name: token
+  description: JWT stored in an HttpOnly cookie, obtained via POST /user/login.

+ 45 - 0
docs/openapi.yaml

@@ -0,0 +1,45 @@
+openapi: 3.0.3
+
+info:
+  title: Torus API
+  version: 0.1.0
+
+servers:
+  - url: http://localhost:8000
+    description: Development
+  - url: https://example.com
+    description: Production
+
+tags:
+  - name: General
+  - name: Users
+  - name: Workouts
+
+paths:
+  /:
+    $ref: './paths/index.yaml'
+  /user:
+    $ref: './paths/user.yaml'
+  /user/login:
+    $ref: './paths/user-login.yaml'
+  /user/logout:
+    $ref: './paths/user-logout.yaml'
+  /workout:
+    $ref: './paths/workout.yaml'
+
+components:
+  securitySchemes:
+    $ref: './components/securitySchemes.yaml'
+  schemas:
+    User:
+      $ref: './components/schemas/User.yaml'
+    Workout:
+      $ref: './components/schemas/Workout.yaml'
+    Exercise:
+      $ref: './components/schemas/Exercise.yaml'
+    ExerciseType:
+      $ref: './components/schemas/ExerciseType.yaml'
+    ErrorResponse:
+      $ref: './components/schemas/ErrorResponse.yaml'
+    SuccessResponse:
+      $ref: './components/schemas/SuccessResponse.yaml'

+ 13 - 0
docs/paths/index.yaml

@@ -0,0 +1,13 @@
+get:
+  summary: Health check
+  operationId: getIndex
+  tags:
+    - General
+  responses:
+    '200':
+      description: API is running.
+      content:
+        text/plain:
+          schema:
+            type: string
+            example: Welcome to the Torus API

+ 51 - 0
docs/paths/user-login.yaml

@@ -0,0 +1,51 @@
+post:
+  summary: Log in
+  operationId: loginUser
+  tags:
+    - Users
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          required:
+            - email
+            - password
+          properties:
+            email:
+              type: string
+              format: email
+              example: lee@example.com
+            password:
+              type: string
+              example: supersecret123
+  responses:
+    '200':
+      description: >
+        Login successful. Sets an HttpOnly `token` cookie containing a signed
+        JWT (HS256) with the user's `id` and `uuid_key`, expiring in 7 days.
+      headers:
+        Set-Cookie:
+          description: HttpOnly auth cookie.
+          schema:
+            type: string
+            example: token=<jwt>; Path=/; HttpOnly; SameSite=Strict
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/SuccessResponse.yaml'
+    '401':
+      description: Invalid credentials.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Invalid email or password
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'

+ 22 - 0
docs/paths/user-logout.yaml

@@ -0,0 +1,22 @@
+post:
+  summary: Log out
+  operationId: logoutUser
+  tags:
+    - Users
+  description: >
+    Clears the `token` cookie by overwriting it with an empty value and
+    `max-age=0`. No authentication is required — the endpoint is always safe
+    to call even if the cookie is already absent or expired.
+  responses:
+    '200':
+      description: Logged out. The `token` cookie is cleared.
+      headers:
+        Set-Cookie:
+          description: Expired auth cookie that instructs the browser to delete it.
+          schema:
+            type: string
+            example: token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/SuccessResponse.yaml'

+ 118 - 0
docs/paths/user.yaml

@@ -0,0 +1,118 @@
+post:
+  summary: Create a new user
+  operationId: createUser
+  tags:
+    - Users
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          required:
+            - name
+            - email
+            - password
+            - confirm_password
+          properties:
+            name:
+              type: string
+              example: Lee
+            email:
+              type: string
+              format: email
+              example: lee@example.com
+            password:
+              type: string
+              minLength: 12
+              example: supersecret123
+            confirm_password:
+              type: string
+              minLength: 12
+              example: supersecret123
+  responses:
+    '201':
+      description: User created successfully.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/SuccessResponse.yaml'
+    '400':
+      description: Validation error (invalid email, passwords do not match, or password too short).
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          examples:
+            invalidEmail:
+              summary: Invalid email address
+              value:
+                error: Invalid email address
+            passwordMismatch:
+              summary: Passwords do not match
+              value:
+                error: Passwords do not match
+            passwordTooShort:
+              summary: Password too short
+              value:
+                error: Password must be at least 12 characters
+    '409':
+      description: Email already in use.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          example:
+            error: Email already in use
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+
+get:
+  summary: Get the current user
+  operationId: getUser
+  tags:
+    - Users
+  security:
+    - cookieAuth: []
+  responses:
+    '200':
+      description: Current user data.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - id
+              - name
+              - email
+            properties:
+              id:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000001
+              name:
+                type: string
+                example: Lee
+              email:
+                type: string
+                format: email
+                example: lee@example.com
+    '401':
+      description: Missing or invalid auth token.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          examples:
+            missing:
+              summary: No cookie present
+              value:
+                error: Missing auth token
+            invalid:
+              summary: Token invalid or expired
+              value:
+                error: Invalid auth token

+ 77 - 0
docs/paths/workout.yaml

@@ -0,0 +1,77 @@
+post:
+  summary: Create a new workout
+  operationId: createWorkout
+  tags:
+    - Workouts
+  security:
+    - cookieAuth: []
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          required:
+            - name
+            - exercises
+          properties:
+            name:
+              type: string
+              example: Push Day
+            exercises:
+              type: array
+              items:
+                type: object
+                required:
+                  - name
+                  - exercise_type
+                properties:
+                  name:
+                    type: string
+                    example: Bench Press
+                  exercise_type:
+                    $ref: '../components/schemas/ExerciseType.yaml'
+        example:
+          name: Push Day
+          exercises:
+            - name: Bench Press
+              exercise_type: WeightedReps
+            - name: Push-ups
+              exercise_type: BodyweightReps
+            - name: Treadmill
+              exercise_type: Cardio
+  responses:
+    '201':
+      description: Workout created. Returns the generated workout ID.
+      content:
+        application/json:
+          schema:
+            type: object
+            required:
+              - id
+            properties:
+              id:
+                type: string
+                format: uuid
+                example: 0191a23b-dead-7000-beef-000000000002
+    '401':
+      description: Missing or invalid auth token.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'
+          examples:
+            missing:
+              summary: No cookie present
+              value:
+                error: Missing auth token
+            invalid:
+              summary: Token invalid or expired
+              value:
+                error: Invalid auth token
+    '500':
+      description: Internal server error.
+      content:
+        application/json:
+          schema:
+            $ref: '../components/schemas/ErrorResponse.yaml'

+ 2 - 0
init.sql

@@ -3,3 +3,5 @@ CREATE DATABASE torus;
 \c torus
 
 \i migrations/0001_create_users.sql
+\i migrations/0002_create_workouts.sql
+\i migrations/0003_create_exercises.sql

+ 4 - 0
migrations/0002_create_workouts.sql

@@ -0,0 +1,4 @@
+CREATE TABLE workouts (
+    id   UUID PRIMARY KEY DEFAULT gen_uuid_v7(),
+    name TEXT NOT NULL
+);

+ 9 - 0
migrations/0003_create_exercises.sql

@@ -0,0 +1,9 @@
+CREATE TYPE exercise_type AS ENUM ('WeightedReps', 'BodyweightReps', 'Cardio', 'TimedCardio');
+
+CREATE TABLE exercises (
+    id            UUID          PRIMARY KEY DEFAULT gen_uuid_v7(),
+    workout       UUID          NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
+    name          TEXT          NOT NULL,
+    notes         TEXT,
+    exercise_type exercise_type NOT NULL
+);

+ 1 - 0
src/main.rs

@@ -53,6 +53,7 @@ async fn main() -> std::io::Result<()> {
             .service(routes::users::login::login)
             .service(routes::users::logout::logout)
             .service(routes::users::get::get_user)
+            .service(routes::workouts::create::create_workout)
     })
     .bind((host.as_str(), port))?
     .run()

+ 42 - 0
src/models/exercise.rs

@@ -0,0 +1,42 @@
+use serde::Deserialize;
+use sqlx::Postgres;
+use uuid::Uuid;
+
+#[derive(sqlx::Type, Deserialize, Clone)]
+#[sqlx(type_name = "exercise_type", rename_all = "PascalCase")]
+pub enum ExerciseType {
+    WeightedReps,
+    BodyweightReps,
+    Cardio,
+    TimedCardio,
+}
+
+#[derive(sqlx::FromRow)]
+pub struct Exercise {
+    pub id: Uuid,
+    pub workout: Uuid,
+    pub name: String,
+    pub notes: Option<String>,
+    pub exercise_type: ExerciseType,
+}
+
+impl Exercise {
+    pub async fn create<'c, E>(
+        executor: E,
+        workout_id: Uuid,
+        name: &str,
+        exercise_type: ExerciseType,
+    ) -> Result<Exercise, sqlx::Error>
+    where
+        E: sqlx::Executor<'c, Database = Postgres>,
+    {
+        sqlx::query_as::<_, Exercise>(
+            "INSERT INTO exercises (workout, name, exercise_type) VALUES ($1, $2, $3) RETURNING *",
+        )
+        .bind(workout_id)
+        .bind(name)
+        .bind(exercise_type)
+        .fetch_one(executor)
+        .await
+    }
+}

+ 2 - 0
src/models/mod.rs

@@ -1 +1,3 @@
+pub mod exercise;
 pub mod user;
+pub mod workout;

+ 27 - 0
src/models/workout.rs

@@ -0,0 +1,27 @@
+use sqlx::{PgPool, Postgres};
+use uuid::Uuid;
+
+#[derive(sqlx::FromRow)]
+pub struct Workout {
+    pub id: Uuid,
+    pub name: String,
+}
+
+impl Workout {
+    pub async fn create<'c, E>(executor: E, name: &str) -> Result<Workout, sqlx::Error>
+    where
+        E: sqlx::Executor<'c, Database = Postgres>,
+    {
+        sqlx::query_as::<_, Workout>("INSERT INTO workouts (name) VALUES ($1) RETURNING *")
+            .bind(name)
+            .fetch_one(executor)
+            .await
+    }
+
+    pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Workout>, sqlx::Error> {
+        sqlx::query_as::<_, Workout>("SELECT * FROM workouts WHERE id = $1")
+            .bind(id)
+            .fetch_optional(pool)
+            .await
+    }
+}

+ 1 - 0
src/routes/mod.rs

@@ -1 +1,2 @@
 pub mod users;
+pub mod workouts;

+ 58 - 0
src/routes/workouts/create.rs

@@ -0,0 +1,58 @@
+use actix_web::{post, web, HttpResponse};
+use serde::Deserialize;
+use serde_json::json;
+use sqlx::PgPool;
+
+use crate::auth::AuthUser;
+use crate::models::exercise::{Exercise, ExerciseType};
+use crate::models::workout::Workout;
+
+#[derive(Deserialize)]
+pub struct CreateExerciseInput {
+    pub name: String,
+    pub exercise_type: ExerciseType,
+}
+
+#[derive(Deserialize)]
+pub struct CreateWorkoutInput {
+    pub name: String,
+    pub exercises: Vec<CreateExerciseInput>,
+}
+
+#[post("/workout")]
+pub async fn create_workout(
+    pool: web::Data<PgPool>,
+    _auth: AuthUser,
+    body: web::Json<CreateWorkoutInput>,
+) -> HttpResponse {
+    let mut tx = match pool.begin().await {
+        Ok(t) => t,
+        Err(_) => {
+            return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}))
+        }
+    };
+
+    let workout = match Workout::create(&mut *tx, &body.name).await {
+        Ok(w) => w,
+        Err(_) => {
+            return HttpResponse::InternalServerError()
+                .json(json!({"error": "Failed to create workout"}))
+        }
+    };
+
+    for exercise in &body.exercises {
+        if Exercise::create(&mut *tx, workout.id, &exercise.name, exercise.exercise_type.clone())
+            .await
+            .is_err()
+        {
+            return HttpResponse::InternalServerError()
+                .json(json!({"error": "Failed to create exercise"}));
+        }
+    }
+
+    if tx.commit().await.is_err() {
+        return HttpResponse::InternalServerError().json(json!({"error": "An error occurred"}));
+    }
+
+    HttpResponse::Created().json(json!({"id": workout.id}))
+}

+ 1 - 0
src/routes/workouts/mod.rs

@@ -0,0 +1 @@
+pub mod create;