Simple Bookstore CRUD Tutorial
This tutorial will guide you through building a simple bookstore management API with CRUD (Create, Read, Update, Delete) operations using the Gonyx Framework. You'll learn how to implement a book inventory system with proper data validation, error handling, and RESTful endpoints.
Prerequisites
Before starting, make sure you have:
- Completed the Quick Start tutorial
- Basic knowledge of Go programming language
- Gonyx Framework installed on your system
Project Setup
First, create a new Gonyx project using the CLI:
gonyx init gonyx-books --path .
cd gonyx-books
This will create a basic project structure as described in the Quick Start tutorial.
Designing Our Application
We're going to build a Book Management API with the following features:
- List all books
- Get a single book by ID
- Create a new book
- Update an existing book
- Delete a book
Let's start by designing our data model and then implement the CRUD operations one by one.
Step 1: Create the Book Model
First, we need to create a new file called app/types.go
. This file doesn't exist in the default project, so we'll need to create it from scratch. This is where we'll define our Book
model and the in-memory storage for our books.
In this file, you'll need to define:
- Book struct: A data structure to represent a book with fields like ID, title, author, etc.
// Book represents a book entity
type Book struct {
ID uint `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
Description string `json:"description"`
PublishedAt time.Time `json:"published_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
- BookStore struct: An in-memory data store with thread-safe operations
// BookStore is an in-memory store for books
type BookStore struct {
mu sync.RWMutex // For thread safety
books map[uint]*Book
nextID uint
}
// Global store instance
var store = &BookStore{
books: make(map[uint]*Book),
nextID: 1,
}
// GetStore returns the global BookStore instance
func GetStore() *BookStore {
return store
}
- Storage operations: Methods to manipulate the book data
For each CRUD operation, you'll need to implement a corresponding method:
CreateBook()
: Adds a new book with validation for duplicate ISBNsGetBookByID()
: Retrieves a single book by its IDGetAllBooks()
: Returns all books in the storeUpdateBook()
: Updates fields of an existing bookDeleteBook()
: Removes a book from the store
Each method needs proper error handling and thread safety using the mutex.
This code defines:
- A
Book
struct with all the fields we need - A thread-safe
BookStore
to manage our books in memory - CRUD operations:
CreateBook
,GetBookByID
,GetAllBooks
,UpdateBook
, andDeleteBook
- A global store instance and a function to access it
Step 2: Implementing CRUD Operations
Now that we have our data model in place, let's implement each of the CRUD operations one by one. For each operation, we'll need to add functionality in both our data store (types.go
) and our controller (controller.go
).
Let's start by creating the basic controller structure in controller.go
:
// BookController - handles CRUD operations for books
type BookController struct{}
// GetName - return the name of the controller to be used as part of the route
func (ctrl *BookController) GetName() string { return "Book" }
// Routes - will contain all our routes
func (ctrl *BookController) Routes() []http.HttpRoute {
return []http.HttpRoute{
// We'll add each route here as we implement them
}
}
2.1 Implementing "List All Books" (GET /books)
Data Store Implementation (types.go)
First, we need a method in our BookStore
to retrieve all books:
// GetAllBooks returns all books in the store
func (bs *BookStore) GetAllBooks() ([]*Book, error) {
// Acquire a read lock to ensure thread safety
bs.mu.RLock()
defer bs.mu.RUnlock()
// Create a slice to hold all books
books := make([]*Book, 0, len(bs.books))
// Add each book to the slice
for _, book := range bs.books {
books = append(books, book)
}
return books, nil
}
This method:
- Uses a read lock (
RLock
) since it only reads data without modifying it - Creates a new slice to hold all the books
- Iterates through the map of books and adds each one to the slice
- Returns the slice along with a nil error (since this operation can't fail)
Controller Implementation (controller.go)
Now, let's implement the handler in our controller:
// GetAllBooks - Get all books
func (ctrl *BookController) GetAllBooks(c *gin.Context) {
// Call our store method to get all books
books, err := GetStore().GetAllBooks()
if err != nil {
// If there's an error, return a 500 status code
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return the books as JSON with a 200 status code
c.JSON(http.StatusOK, books)
}
Finally, add this route to the Routes()
method:
func (ctrl *BookController) Routes() []http.HttpRoute {
return []http.HttpRoute{
{
Method: http.MethodGet,
Path: "/books",
RouteName: "getAllBooks",
F: ctrl.GetAllBooks,
},
// More routes will be added here
}
}
2.2 Implementing "Get Book by ID" (GET /books/:id)
Data Store Implementation (types.go)
We need a method to retrieve a single book by its ID:
// GetBookByID retrieves a book by its ID
func (bs *BookStore) GetBookByID(id uint) (*Book, error) {
// Acquire a read lock
bs.mu.RLock()
defer bs.mu.RUnlock()
// Look up the book in our map
book, exists := bs.books[id]
if !exists {
// Return an error if the book doesn't exist
return nil, errors.New("book not found")
}
return book, nil
}
This method:
- Uses a read lock for thread safety
- Checks if the book exists in our map using the ID as the key
- Returns the book if found, or an error if not
Controller Implementation (controller.go)
Implement the handler to get a book by ID:
// GetBookById - Get a specific book by ID
func (ctrl *BookController) GetBookById(c *gin.Context) {
// Extract the ID parameter from the URL
idStr := c.Param("id")
// Convert the string ID to a uint
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
// Return a 400 error if the ID is invalid
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"})
return
}
// Try to get the book from our store
book, err := GetStore().GetBookByID(uint(id))
if err != nil {
// Return a 404 error if the book isn't found
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}
// Return the book as JSON
c.JSON(http.StatusOK, book)
}
Add this route to the Routes()
method:
{
Method: http.MethodGet,
Path: "/books/:id", // The :id part is a path parameter
RouteName: "getBookById",
F: ctrl.GetBookById,
},
2.3 Implementing "Create Book" (POST /books)
Data Store Implementation (types.go)
Add a method to create a new book:
// CreateBook adds a new book to the store
func (bs *BookStore) CreateBook(book *Book) error {
// Acquire a write lock since we're modifying data
bs.mu.Lock()
defer bs.mu.Unlock()
// Check if ISBN already exists to avoid duplicates
for _, b := range bs.books {
if b.ISBN == book.ISBN && book.ISBN != "" {
return errors.New("book with this ISBN already exists")
}
}
// Assign an ID and timestamps
book.ID = bs.nextID
book.CreatedAt = time.Now()
book.UpdatedAt = time.Now()
// Store the book in our map
bs.books[book.ID] = book
// Increment the next ID
bs.nextID++
return nil
}
This method:
- Uses a write lock since we're modifying the data store
- Checks for duplicate ISBNs to maintain data integrity
- Assigns an ID and timestamps to the new book
- Stores the book in our map and increments the next ID
Controller Implementation (controller.go)
Implement the handler to create a new book:
// CreateBook - Create a new book
func (ctrl *BookController) CreateBook(c *gin.Context) {
// Create a book variable to store the parsed data
var book Book
// Parse the JSON from the request body
if err := c.ShouldBindJSON(&book); err != nil {
// Return a 400 error if JSON parsing fails
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try to create the book in our store
if err := GetStore().CreateBook(&book); err != nil {
// Return a 500 error if creation fails
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return the created book with a 201 status code
c.JSON(http.StatusCreated, book)
}
Add this route to the Routes()
method:
{
Method: http.MethodPost,
Path: "/books",
RouteName: "createBook",
F: ctrl.CreateBook,
},
2.4 Implementing "Update Book" (PUT /books/:id)
Data Store Implementation (types.go)
Add a method to update an existing book:
// UpdateBook updates an existing book
func (bs *BookStore) UpdateBook(id uint, updatedBook Book) error {
// Acquire a write lock
bs.mu.Lock()
defer bs.mu.Unlock()
// Check if the book exists
book, exists := bs.books[id]
if !exists {
return errors.New("book not found")
}
// Check if ISBN is being changed and if it conflicts with another book
if updatedBook.ISBN != "" && updatedBook.ISBN != book.ISBN {
for _, b := range bs.books {
if b.ISBN == updatedBook.ISBN && b.ID != id {
return errors.New("another book with this ISBN already exists")
}
}
}
// Update fields if they are provided in the request
if updatedBook.Title != "" {
book.Title = updatedBook.Title
}
if updatedBook.Author != "" {
book.Author = updatedBook.Author
}
if updatedBook.ISBN != "" {
book.ISBN = updatedBook.ISBN
}
if updatedBook.Description != "" {
book.Description = updatedBook.Description
}
if !updatedBook.PublishedAt.IsZero() {
book.PublishedAt = updatedBook.PublishedAt
}
// Update the timestamp
book.UpdatedAt = time.Now()
return nil
}
This method:
- Uses a write lock since we're modifying data
- Checks if the book exists
- Validates the ISBN if it's being changed
- Updates only the fields that are provided in the request
- Updates the timestamp
Controller Implementation (controller.go)
Implement the handler to update a book:
// UpdateBook - Update an existing book
func (ctrl *BookController) UpdateBook(c *gin.Context) {
// Extract and validate the ID parameter
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"})
return
}
// Check if book exists before updating
_, err = GetStore().GetBookByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}
// Parse the update data from JSON
var book Book
if err := c.ShouldBindJSON(&book); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Attempt to update the book
if err := GetStore().UpdateBook(uint(id), book); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get the updated book to return in the response
updatedBook, _ := GetStore().GetBookByID(uint(id))
c.JSON(http.StatusOK, updatedBook)
}
Add this route to the Routes()
method:
{
Method: http.MethodPut,
Path: "/books/:id",
RouteName: "updateBook",
F: ctrl.UpdateBook,
},
2.5 Implementing "Delete Book" (DELETE /books/:id)
Data Store Implementation (types.go)
Add a method to delete a book:
// DeleteBook removes a book from the store
func (bs *BookStore) DeleteBook(id uint) error {
// Acquire a write lock
bs.mu.Lock()
defer bs.mu.Unlock()
// Check if the book exists
if _, exists := bs.books[id]; !exists {
return errors.New("book not found")
}
// Delete the book from the map
delete(bs.books, id)
return nil
}
This method:
- Uses a write lock since we're modifying data
- Checks if the book exists
- Deletes the book from the map if it exists
Controller Implementation (controller.go)
Implement the handler to delete a book:
// DeleteBook - Delete a book
func (ctrl *BookController) DeleteBook(c *gin.Context) {
// Extract and validate the ID parameter
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid book ID"})
return
}
// Check if book exists before deleting
_, err = GetStore().GetBookByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}
// Attempt to delete the book
if err := GetStore().DeleteBook(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return a success message
c.JSON(http.StatusOK, gin.H{"message": "Book successfully deleted"})
}
Add this route to the Routes()
method:
{
Method: http.MethodDelete,
Path: "/books/:id",
RouteName: "deleteBook",
F: ctrl.DeleteBook,
},
Complete Routes Function
After implementing all the CRUD operations, your complete Routes()
function should look like this:
func (ctrl *BookController) Routes() []http.HttpRoute {
return []http.HttpRoute{
{
Method: http.MethodGet,
Path: "/books",
RouteName: "getAllBooks",
F: ctrl.GetAllBooks,
},
{
Method: http.MethodGet,
Path: "/books/:id",
RouteName: "getBookById",
F: ctrl.GetBookById,
},
{
Method: http.MethodPost,
Path: "/books",
RouteName: "createBook",
F: ctrl.CreateBook,
},
{
Method: http.MethodPut,
Path: "/books/:id",
RouteName: "updateBook",
F: ctrl.UpdateBook,
},
{
Method: http.MethodDelete,
Path: "/books/:id",
RouteName: "deleteBook",
F: ctrl.DeleteBook,
},
}
}
This completes the implementation of all five CRUD operations in your Gonyx application.
Step 3: Update the App Entry Point
The last file we need to modify is app/app.go
. This file is responsible for initializing the application and registering controllers with the Gonyx engine.
What to change in app.go
In the default project, there's a SampleController
registered in the Init()
method. We need to change this to register our BookController
instead.
Inside the Init()
method, replace:
engine.RegisterRestfulController(&SampleController{})
With:
engine.RegisterRestfulController(&BookController{})
This tells the Gonyx engine to use our BookController for handling HTTP requests. You can leave the gRPC controller registration as is if you don't need to modify it.
Key Changes Summary
Here's what we've changed from the default project:
-
Created new file:
app/types.go
which defines our data model and storage operations
-
Modified existing files:
app/controller.go
: Replaced the sample controller with our Book controllerapp/app.go
: Updated to register our new controller
-
Implementing features:
- Defined a Book data model with relevant fields
- Created a thread-safe in-memory storage
- Implemented five RESTful endpoints for CRUD operations
- Added proper validation and error handling
Testing Your API
Once you've implemented all the changes, you can run your application with:
gonyx runserver
Test each endpoint using curl commands, for example:
# Create a new book
curl -X POST http://localhost:8000/api/v1/Book/books \
-H "Content-Type: application/json" \
-d '{
"title": "The Go Programming Language",
"author": "Alan A. A. Donovan & Brian W. Kernighan",
"isbn": "978-0134190440"
}'
# Get all books
curl http://localhost:8000/api/v1/Book/books
Going Further
This tutorial implemented a simple in-memory book store. For a production application, consider:
- Using a database instead of in-memory storage
- Adding authentication and authorization
- Implementing request validation middleware
- Adding pagination for listing endpoints
- Implementing proper error types and handling
Conclusion
You've learned how to implement a complete REST API with CRUD operations using the Gonyx Framework. This pattern can be extended to build more complex applications by adding more models, controllers, and business logic.
Remember that Gonyx helps you maintain a clean structure while providing the flexibility to implement your business requirements efficiently.