diff --git a/ApplicationDescription.md b/ApplicationDescription.md index 1b678eb..cb0df39 100644 --- a/ApplicationDescription.md +++ b/ApplicationDescription.md @@ -1,30 +1,35 @@ # Application Overview: + I want to build a back-end application using Go that provides an API for managing boxes and items stored in those boxes. The app should be hosted in a Docker container and use SQLite3 as the database. Additionally, I want a config.yaml file to manage configuration (database path, JWT secret, and image storage directory). It should also support JWT-based authentication with a default user of 'boxuser' and password 'boxuser'. I would like it to log all logins, box creation/deletion, and item creation/deletion to a local log file, specified in config.yaml. -# Database Tables: +## Database Tables + - `boxes`: A table containing an ID and a name. - `items`: A table containing an item name, description, the ID of the box it is stored in, and an optional path to an image of the item. - `users`: A table containing usernames and passwords (hashed) for authentication. -# API Endpoints: +## API Endpoints + 1. Authentication: - POST `/login`: Authenticates a user and returns a JWT. 2. Boxes: - GET `/boxes`: Retrieves all boxes. - POST `/boxes`: Creates a new box. - DELETE :`/boxes/{id}`: Deletes a box by its ID. + - GET `/boxes/{id}/items`: Retrieves all items in box with this id. + 3. Items: - GET `/items`: Retrieves all items, optionally searchable by description. - POST `/items`: Adds a new item to a box. - GET `/items/{id}`: Retrieves an item by its ID. - PUT `/items/{id}`: Updates an existing item. - - GET `/items/{id}/items`: Retrieves all items in box with this id. - DELETE `/items/{id}`: Deletes an item by its ID. - GET `/items/{id}/image`: Retrieves the image of an item. -# Additional Details: +## Additional Details + - If the database doesn’t exist, it should be created automatically when the app starts. - Images should be stored locally, and their paths should be saved in the database. - The default user for the app should be 'boxuser' with a password of 'boxuser'. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d6537b --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Boxes API + +## Overview + +This is a back-end application written in Go that provides an API for managing boxes and items stored within those boxes. It uses SQLite3 as its database and supports JWT-based authentication. + +## Features + +- **Box Management:** Create, retrieve, and delete boxes. +- **Item Management:** Add, retrieve, update, and delete items within boxes. +- **Image Storage:** Store images associated with items. +- **JWT Authentication:** Secure API access with JSON Web Tokens. +- **Configuration:** Manage settings via a `config.yaml` file. +- **Logging:** Logs user logins and box/item actions to a file. + +## API Documentation + +See the [API Specification](api_specification.md) for detailed information on endpoints, request/response formats, and authentication. + +## Getting Started + +1. **Clone the repository:** + + ```bash + + git clone https://github.com/your-username/boxes-api.git + + ``` + +2. Configuration: + + - Create a config.yaml file in the project root directory. + - Refer to the config.yaml.example file for available settings. + +3. Database Setup: + + - The database will be automatically created if it doesn't exist. + - Configure the database path in the config.yaml file. + +4. Running the Application: + + - Build and run the Go application: + + ```bash + + go run boxes-api . + + ``` + +5. Run the test script: + + ``` bash + + ./tests.bash + + ``` + +## Podman Support + +Build the podman image: + + ```bash + + podman build -t boxes-api . + + ``` + +Run the podman container: + + ```bash + + podman run \ + -e CONFIG="/app/config/config.yaml" \ + -v /Users/stwhite/CODE/boxes/data:/app/data \ + -v /Users/stwhite/CODE/boxes/images:/app/images \ + -v /Users/stwhite/CODE/boxes/config:/app/config \ + -p 8080:8080 \ + --name boxes-api-container \ + localhost/boxes-api + + ``` diff --git a/addItemToBox.bash b/addItemToBox.bash new file mode 100644 index 0000000..ee53ecb --- /dev/null +++ b/addItemToBox.bash @@ -0,0 +1,29 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080" # Replace with your actual API base URL + +# Box ID +BOX_ID=96 # Replace with the actual box ID + +# Login credentials +USERNAME="boxuser" # Replace with your actual username +PASSWORD="boxuser" # Replace with your actual password + +# Item data +ITEM_NAME="New Item" +ITEM_DESCRIPTION="This is a new item" + +# Get a new JWT token +TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + +# Create the item data in JSON format +ITEM_DATA=$(echo "{\"name\":\"$ITEM_NAME\",\"description\":\"$ITEM_DESCRIPTION\",\"box_id\":$BOX_ID}" | jq -r '.') + +# Add the item to the box using the obtained token +curl -s -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$ITEM_DATA" \ + "$API_BASE_URL/items" diff --git a/deleteItemsInBox.bash b/deleteItemsInBox.bash new file mode 100644 index 0000000..c5ccf33 --- /dev/null +++ b/deleteItemsInBox.bash @@ -0,0 +1,28 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080" # Replace with your actual API base URL + +# Box ID to delete items from +BOX_ID=0 # Replace with the actual box ID + +# Login credentials +USERNAME="boxuser" # Replace with your actual username +PASSWORD="boxuser" # Replace with your actual password + +# Get a new JWT token +TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + +# Get a list of all item IDs associated with the specified box ID +ITEM_IDS=$(curl -s -X GET -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE_URL/boxes/$BOX_ID/items" | jq -r '.[].ID') + +# Loop through each item ID and send a DELETE request +for ITEM_ID in $ITEM_IDS; do + curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE_URL/items/$ITEM_ID" +done diff --git a/getitems.bash b/getitems.bash new file mode 100644 index 0000000..5692f4c --- /dev/null +++ b/getitems.bash @@ -0,0 +1,21 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080" + +# Login credentials +USERNAME="boxuser" +PASSWORD="boxuser" + +# Get a new JWT token +TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + +# Request all items using the obtained token +response=$(curl -s -X GET -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE_URL/items") + +# Print the response +echo "$response" diff --git a/getitemsinbox.bash b/getitemsinbox.bash new file mode 100644 index 0000000..b84215c --- /dev/null +++ b/getitemsinbox.bash @@ -0,0 +1,21 @@ +#!/bin/bash + +# API base URL +API_BASE_URL="http://localhost:8080" # Replace with your actual API base URL + +# Box ID +BOX_ID=0 # Replace with the actual box ID + +# Login credentials +USERNAME="boxuser" # Replace with your actual username +PASSWORD="boxuser" # Replace with your actual password + +# Get a new JWT token +TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$USERNAME\", \"password\":\"$PASSWORD\"}" \ + "$API_BASE_URL/login" | jq -r '.token') + +# Request items in the specified box using the obtained token +curl -s -X GET -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_BASE_URL/boxes/$BOX_ID/items" diff --git a/handlers.go b/handlers.go index 266bf14..dca636d 100644 --- a/handlers.go +++ b/handlers.go @@ -68,6 +68,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { // getBoxesHandler handles the GET /boxes endpoint. func GetBoxesHandler(w http.ResponseWriter, r *http.Request) { + fmt.Printf("Received %s request to %s\n", r.Method, r.URL) var boxes []Box db.Find(&boxes) json.NewEncoder(w).Encode(boxes) @@ -129,11 +130,13 @@ func GetItemsHandler(w http.ResponseWriter, r *http.Request) { // createItemHandler handles the POST /items endpoint. func CreateItemHandler(w http.ResponseWriter, r *http.Request) { var item Item + err := json.NewDecoder(r.Body).Decode(&item) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } + fmt.Println(item) db.Create(&item) @@ -234,7 +237,7 @@ func GetItemHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(item) } -// getItemsInBoxHandler handles the GET /items/{id}/items endpoint. +// getItemsInBoxHandler handles the GET /boxes/{id}/items endpoint. func GetItemsInBoxHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] diff --git a/main.go b/main.go index 2fa0156..f39f382 100644 --- a/main.go +++ b/main.go @@ -54,7 +54,7 @@ func main() { router.Handle("/items", AuthMiddleware(http.HandlerFunc(GetItemsHandler))).Methods("GET", "OPTIONS") router.Handle("/items", AuthMiddleware(http.HandlerFunc(CreateItemHandler))).Methods("POST", "OPTIONS") router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(GetItemHandler))).Methods("GET", "OPTIONS") - router.Handle("/items/{id}/items", AuthMiddleware(http.HandlerFunc(GetItemsInBoxHandler))).Methods("GET", "OPTIONS") + router.Handle("/boxes/{id}/items", AuthMiddleware(http.HandlerFunc(GetItemsInBoxHandler))).Methods("GET", "OPTIONS") router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(UpdateItemHandler))).Methods("PUT", "OPTIONS") router.Handle("/items/{id}", AuthMiddleware(http.HandlerFunc(DeleteItemHandler))).Methods("DELETE", "OPTIONS") // Add a new route for uploading an image with AuthMiddleware diff --git a/main_test.go b/main_test.go deleted file mode 100644 index c19817c..0000000 --- a/main_test.go +++ /dev/null @@ -1,348 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gorilla/mux" - "github.com/stretchr/testify/assert" -) - -func TestMain(m *testing.M) { - // Load the configuration - var err error - config, err = LoadConfig("config.yaml") - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Set the environment variable for the test database - os.Setenv("TEST_DATABASE_PATH", "data/my_test_database.db") - config.DatabasePath = os.Getenv("TEST_DATABASE_PATH") - - // Connect to the database using the test database path - db, err = ConnectDB(config.DatabasePath) - if err != nil { - log.Fatalf("Failed to connect to test database: %v", err) - } - - fmt.Println("DB is connected") - - defer db.Close() - - // Truncate tables before running tests - for _, table := range []string{"boxes", "items", "users"} { // Add all your table names here - if err := db.Exec(fmt.Sprintf("DELETE FROM %s", table)).Error; err != nil { - log.Fatalf("Failed to truncate table %s: %v", table, err) - } - } - - db.LogMode(true) - - // Run the tests - exitCode := m.Run() - - os.Exit(exitCode) -} -func TestGetBoxes(t *testing.T) { - - // 1. Create a request (no need for a real token in testing) - req := httptest.NewRequest("GET", "/boxes", nil) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - - // 2. Create a recorder - rr := httptest.NewRecorder() - - // 3. Initialize your router - router := mux.NewRouter() - router.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET") - - // 4. Serve the request - router.ServeHTTP(rr, req) - - // 5. Assert the response - assert.Equal(t, http.StatusOK, rr.Code) - // Add more assertions to check response body, headers, etc. -} - -func TestCreateBox(t *testing.T) { - // 1. Create a request with a new box in the body - newBox := Box{Name: "Test Box"} - reqBody, _ := json.Marshal(newBox) - req := httptest.NewRequest("POST", "/boxes", bytes.NewBuffer(reqBody)) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - req.Header.Set("Content-Type", "application/json") - - // 2. Create a recorder - rr := httptest.NewRecorder() - - // 3. Initialize your router - router := mux.NewRouter() - router.Handle("/boxes", http.HandlerFunc(CreateBoxHandler)).Methods("POST") - - // 4. Serve the request - router.ServeHTTP(rr, req) - - // 5. Assert the response - assert.Equal(t, http.StatusOK, rr.Code) - - // 6. Decode the response body - var createdBox Box - json.Unmarshal(rr.Body.Bytes(), &createdBox) - - // 7. Assert the created box - assert.Equal(t, newBox.Name, createdBox.Name) -} - -func TestGetItem(t *testing.T) { - // Create a test item in the database - testItem := Item{Name: "Test Item", Description: "Test Description", BoxID: 1} - db.Create(&testItem) - - // Create a request to get the test item - req := httptest.NewRequest("GET", fmt.Sprintf("/items/%d", testItem.ID), nil) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - - // Create a response recorder - rr := httptest.NewRecorder() - - // Initialize the router - router := mux.NewRouter() - router.Handle("/items/{id}", http.HandlerFunc(GetItemHandler)).Methods("GET") - - // Serve the request - router.ServeHTTP(rr, req) - - // Check the response status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Decode the response body - var retrievedItem Item - err := json.Unmarshal(rr.Body.Bytes(), &retrievedItem) - assert.NoError(t, err) - - // Check if the retrieved item matches the test item - assert.Equal(t, testItem.ID, retrievedItem.ID) - assert.Equal(t, testItem.Name, retrievedItem.Name) - assert.Equal(t, testItem.Description, retrievedItem.Description) - assert.Equal(t, testItem.BoxID, retrievedItem.BoxID) - - fmt.Println("TestGetItem") -} - -func TestGetItemsInBox(t *testing.T) { - // Create test items associated with a specific box - testBox := Box{Name: "Test Box for Items"} - fmt.Println("testBox.ID (before create):", testBox.ID) // Should be 0 - - if err := db.Create(&testBox).Error; err != nil { // Check for errors! - t.Fatalf("Failed to create test box: %v", err) - } - - // temporarily disable callbacks - db.Callback().Create().Replace("gorm:create", nil) - - fmt.Println("testBox.ID (after create):", testBox.ID) // Should be a non-zero value - - defaultImagePath := "default.jpg" - - testItems := []Item{ - {Name: "Item 1", Description: "Description 1", BoxID: testBox.ID, ImagePath: &defaultImagePath}, // Use "" for empty string - {Name: "Item 2", Description: "Description 2", BoxID: testBox.ID, ImagePath: &defaultImagePath}, // Use "" for empty string - } - - fmt.Println("Right before creating test items in database") - - // Marshal the testItems slice to JSON - jsonData, err := json.MarshalIndent(testItems, "", " ") // Use " " for indentation - if err != nil { - t.Fatalf("Failed to marshal testItems to JSON: %v", err) - } - - // Print the formatted JSON - fmt.Println("testItems:", string(jsonData)) - - if err := db.Create(&testItems).Error; err != nil { // Check for errors! - t.Fatalf("Failed to create test items: %v", err) - } - fmt.Println("Right AFTER creating test items in database") - - // Create a request to get items in the test box - req := httptest.NewRequest("GET", fmt.Sprintf("/items/%d/items", testBox.ID), nil) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - - // Create a response recorder - rr := httptest.NewRecorder() - - // Initialize the router - router := mux.NewRouter() - router.Handle("/items/{id}/items", http.HandlerFunc(GetItemsInBoxHandler)).Methods("GET") - - // Serve the request - router.ServeHTTP(rr, req) - - // Check the response status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Decode the response body - var retrievedItems []Item - err = json.Unmarshal(rr.Body.Bytes(), &retrievedItems) - assert.NoError(t, err) - - // Check if the correct number of items is retrieved - assert.Equal(t, len(testItems), len(retrievedItems)) - - // You can add more assertions to check the content of retrievedItems -} - -func TestUpdateItem(t *testing.T) { - // Create a test item in the database - testItem := Item{Name: "Test Item", Description: "Test Description", BoxID: 1} - db.Create(&testItem) - - // Create a request to update the test item - updatedItem := Item{Name: "Updated Item", Description: "Updated Description"} - reqBody, _ := json.Marshal(updatedItem) - req := httptest.NewRequest("PUT", fmt.Sprintf("/items/%d", testItem.ID), bytes.NewBuffer(reqBody)) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - req.Header.Set("Content-Type", "application/json") - - // Create a response recorder - rr := httptest.NewRecorder() - - // Initialize the router - router := mux.NewRouter() - router.Handle("/items/{id}", http.HandlerFunc(UpdateItemHandler)).Methods("PUT") - - // Serve the request - router.ServeHTTP(rr, req) - - // Check the response status code - assert.Equal(t, http.StatusOK, rr.Code) - - // Retrieve the updated item from the database - var dbItem Item - db.First(&dbItem, testItem.ID) - - // Check if the item is updated in the database - assert.Equal(t, updatedItem.Name, dbItem.Name) - assert.Equal(t, updatedItem.Description, dbItem.Description) -} - -func TestDeleteItem(t *testing.T) { - // Create a test item in the database - testItem := Item{Name: "Test Item", Description: "Test Description", BoxID: 1} - db.Create(&testItem) - - // Create a request to delete the test item - req := httptest.NewRequest("DELETE", fmt.Sprintf("/items/%d", testItem.ID), nil) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - - // Create a response recorder - rr := httptest.NewRecorder() - - // Initialize the router - router := mux.NewRouter() - router.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE") - - // Serve the request - router.ServeHTTP(rr, req) - - // Check the response status code - assert.Equal(t, http.StatusNoContent, rr.Code) - - // Try to retrieve the deleted item from the database - var deletedItem Item - err := db.First(&deletedItem, testItem.ID).Error - assert.Error(t, err) // Expect an error because the item should be deleted -} - -func TestCreateItem(t *testing.T) { - // 1. Create a request with a new item in the body - newItem := Item{Name: "Test Item", Description: "Test Description", BoxID: 1} - reqBody, _ := json.Marshal(newItem) - req := httptest.NewRequest("POST", "/items", bytes.NewBuffer(reqBody)) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - req.Header.Set("Content-Type", "application/json") - - // 2. Create a recorder - rr := httptest.NewRecorder() - - // 3. Initialize your router - router := mux.NewRouter() - router.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST") - - // 4. Serve the request - router.ServeHTTP(rr, req) - - // 5. Assert the response - assert.Equal(t, http.StatusOK, rr.Code) - - // 6. Decode the response body - var createdItem Item - json.Unmarshal(rr.Body.Bytes(), &createdItem) - - // 7. Assert the created item - assert.Equal(t, newItem.Name, createdItem.Name) - assert.Equal(t, newItem.Description, createdItem.Description) - assert.Equal(t, newItem.BoxID, createdItem.BoxID) -} - -func TestGetItems(t *testing.T) { - // 1. Create a request (no need for a real token in testing) - req := httptest.NewRequest("GET", "/items", nil) - req = req.WithContext(context.WithValue(req.Context(), userKey, "testuser")) // Simulate authenticated user - - // 2. Create a recorder - rr := httptest.NewRecorder() - - // 3. Initialize your router - router := mux.NewRouter() - router.Handle("/items", http.HandlerFunc(GetItemsHandler)).Methods("GET") - - // 4. Serve the request - router.ServeHTTP(rr, req) - - // 5. Assert the response - assert.Equal(t, http.StatusOK, rr.Code) - // Add more assertions to check response body, headers, etc. -} - -func ExampleLoginHandler() { - // Create a request with login credentials - loginReq := LoginRequest{ - Username: "testuser", - Password: "testpassword", - } - reqBody, _ := json.Marshal(loginReq) - - req := httptest.NewRequest("POST", "/login", bytes.NewBuffer(reqBody)) - req.Header.Set("Content-Type", "application/json") - - // Create a response recorder - rr := httptest.NewRecorder() - - // Create a test handler (usually your LoginHandler) - handler := http.HandlerFunc(LoginHandler) - - // Serve the request - handler.ServeHTTP(rr, req) - - // Check the response status code - if rr.Code != http.StatusOK { - fmt.Printf("Login failed with status code: %d\n", rr.Code) - } else { - // Decode the response body to get the token - var loginResp LoginResponse - json.Unmarshal(rr.Body.Bytes(), &loginResp) - - fmt.Println("Login successful! Token:", loginResp.Token) - } -}