Thunder Cinema API Tutorial
March 27, 2025 · View on GitHub
Author: Raezil
Created: 2025-03-17 13:54:00 UTC
This tutorial will guide you through creating a Cinema API using the Thunder framework from Raezil/Thunder. We will implement a service for managing movies and screenings with both gRPC and REST endpoints.
Step 0: Prerequisites
- Thunder framework installed
- Go 1.23 or later
- PostgreSQL database
- Basic understanding of gRPC, REST, and Prisma ORM
Step 1: Initialize project
./install.sh
thunder init cinema-api
Step 2: Define the Protocol Buffer
Create the file cinema.proto with the content below:
syntax = "proto3";
package cinema;
option go_package = "./pkg/services/generated";
import "google/api/annotations.proto";
service Cinema {
// List all movies
rpc ListMovies(ListMoviesRequest) returns (ListMoviesResponse) {
option (google.api.http) = {
get: "/v1/cinema/movies"
};
}
// Get movie details
rpc GetMovie(GetMovieRequest) returns (Movie) {
option (google.api.http) = {
get: "/v1/cinema/movies/{id}"
};
}
// Create a new movie
rpc CreateMovie(CreateMovieRequest) returns (Movie) {
option (google.api.http) = {
post: "/v1/cinema/movies"
body: "*"
};
}
// Update movie details
rpc UpdateMovie(UpdateMovieRequest) returns (Movie) {
option (google.api.http) = {
put: "/v1/cinema/movies/{id}"
body: "*"
};
}
// Delete a movie
rpc DeleteMovie(DeleteMovieRequest) returns (DeleteMovieResponse) {
option (google.api.http) = {
delete: "/v1/cinema/movies/{id}"
};
}
// List all screenings
rpc ListScreenings(ListScreeningsRequest) returns (ListScreeningsResponse) {
option (google.api.http) = {
get: "/v1/cinema/screenings"
};
}
// Create a new screening
rpc CreateScreening(CreateScreeningRequest) returns (Screening) {
option (google.api.http) = {
post: "/v1/cinema/screenings"
body: "*"
};
}
}
message Movie {
string id = 1;
string title = 2;
string description = 3;
int32 duration = 4; // in minutes
string genre = 5;
string release_date = 6;
}
message ListMoviesRequest {
int32 page_size = 1;
int32 page = 2;
}
message ListMoviesResponse {
repeated Movie movies = 1;
int32 total = 2;
}
message GetMovieRequest {
string id = 1;
}
message CreateMovieRequest {
string title = 1;
string description = 2;
int32 duration = 3;
string genre = 4;
string release_date = 5;
}
message UpdateMovieRequest {
string id = 1;
string title = 2;
string description = 3;
int32 duration = 4;
string genre = 5;
string release_date = 6;
}
message DeleteMovieRequest {
string id = 1;
}
message DeleteMovieResponse {
bool success = 1;
}
message Screening {
string id = 1;
string movie_id = 2;
string screen_number = 3;
string start_time = 4;
int32 available_seats = 5;
}
message ListScreeningsRequest {
string movie_id = 1;
string date = 2;
int32 page_size = 3;
int32 page = 4;
}
message ListScreeningsResponse {
repeated Screening screenings = 1;
int32 total = 2;
}
message CreateScreeningRequest {
string movie_id = 1;
string screen_number = 2;
string start_time = 3;
int32 available_seats = 4;
}
Step 3: Update Services Configuration
Update your services.json to include the new Cinema service. Modify the file as follows:
[
{
"ServiceName": "Auth",
"ServiceStruct": "AuthServiceServer",
"ServiceRegister": "RegisterAuthServer",
"HandlerRegister": "RegisterAuthHandler"
},
{
"ServiceName": "Cinema",
"ServiceStruct": "CinemaServiceServer",
"ServiceRegister": "RegisterCinemaServer",
"HandlerRegister": "RegisterCinemaHandler"
}
]
Step 4: Define Database Schema
Add the following models in your Prisma schema file (prisma/schema.prisma):
model Movie {
id String @id @default(cuid())
title String
description String
duration Int // in minutes
genre String
releaseDate DateTime
screenings Screening[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Screening {
id String @id @default(cuid())
movie Movie @relation(fields: [movieId], references: [id])
movieId String
screenNumber String
startTime DateTime
availableSeats Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([movieId])
@@index([startTime])
}
Step 5: Code Generation and Database Migration
Generate the gRPC and Prisma code by running the following commands from your project root:
thunder generate --proto=cinema.proto
Step 6: Implement Cinema Service
Update the file pkg/services/cinema_server.go with the Prisma implementation. This file implements the Cinema service which handles CRUD operations for Movies and Screenings.
package services
import (
"context"
"time"
"db"
pb "generated"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type CinemaServiceServer struct {
pb.UnimplementedCinemaServer
PrismaClient *db.PrismaClient
Logger *zap.SugaredLogger
}
// ListMovies implements the ListMovies RPC method.
func (s *CinemaServiceServer) ListMovies(ctx context.Context, req *pb.ListMoviesRequest) (*pb.ListMoviesResponse, error) {
pageSize := int(req.PageSize)
if pageSize <= 0 {
pageSize = 10
}
page := int(req.Page)
if page <= 0 {
page = 1
}
skip := (page - 1) * pageSize
movies, err := s.PrismaClient.Movie.FindMany().
OrderBy(db.Movie.CreatedAt.Order(db.DESC)).
Skip(skip).
Take(pageSize).
Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to fetch movies: %v", err)
}
totalMovies, err := s.PrismaClient.Movie.FindMany().Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to fetch movies: %v", err)
}
total := len(totalMovies)
pbMovies := make([]*pb.Movie, len(movies))
for i, movie := range movies {
pbMovies[i] = &pb.Movie{
Id: movie.ID,
Title: movie.Title,
Description: movie.Description,
Duration: int32(movie.Duration),
Genre: movie.Genre,
ReleaseDate: movie.ReleaseDate.Format("2006-01-02"),
}
}
return &pb.ListMoviesResponse{
Movies: pbMovies,
Total: int32(total),
}, nil
}
// GetMovie implements the GetMovie RPC method.
func (s *CinemaServiceServer) GetMovie(ctx context.Context, req *pb.GetMovieRequest) (*pb.Movie, error) {
movie, err := s.PrismaClient.Movie.FindUnique(
db.Movie.ID.Equals(req.Id),
).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.NotFound, "Movie not found: %v", err)
}
return &pb.Movie{
Id: movie.ID,
Title: movie.Title,
Description: movie.Description,
Duration: int32(movie.Duration),
Genre: movie.Genre,
ReleaseDate: movie.ReleaseDate.Format("2006-01-02"),
}, nil
}
// CreateMovie implements the CreateMovie RPC method.
func (s *CinemaServiceServer) CreateMovie(ctx context.Context, req *pb.CreateMovieRequest) (*pb.Movie, error) {
releaseDate, err := time.Parse("2006-01-02", req.ReleaseDate)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "Invalid release date format: %v", err)
}
movie, err := s.PrismaClient.Movie.CreateOne(
db.Movie.Title.Set(req.Title),
db.Movie.Description.Set(req.Description),
db.Movie.Duration.Set(int(req.Duration)),
db.Movie.Genre.Set(req.Genre),
db.Movie.ReleaseDate.Set(releaseDate),
).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to create movie: %v", err)
}
return &pb.Movie{
Id: movie.ID,
Title: movie.Title,
Description: movie.Description,
Duration: int32(movie.Duration),
Genre: movie.Genre,
ReleaseDate: movie.ReleaseDate.Format("2006-01-02"),
}, nil
}
// UpdateMovie implements the UpdateMovie RPC method.
func (s *CinemaServiceServer) UpdateMovie(ctx context.Context, req *pb.UpdateMovieRequest) (*pb.Movie, error) {
releaseDate, err := time.Parse("2006-01-02", req.ReleaseDate)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "Invalid release date format: %v", err)
}
movie, err := s.PrismaClient.Movie.FindUnique(
db.Movie.ID.Equals(req.Id),
).Update(
db.Movie.Title.Set(req.Title),
db.Movie.Description.Set(req.Description),
db.Movie.Duration.Set(int(req.Duration)),
db.Movie.Genre.Set(req.Genre),
db.Movie.ReleaseDate.Set(releaseDate),
).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to update movie: %v", err)
}
return &pb.Movie{
Id: movie.ID,
Title: movie.Title,
Description: movie.Description,
Duration: int32(movie.Duration),
Genre: movie.Genre,
ReleaseDate: movie.ReleaseDate.Format("2006-01-02"),
}, nil
}
// DeleteMovie implements the DeleteMovie RPC method.
func (s *CinemaServiceServer) DeleteMovie(ctx context.Context, req *pb.DeleteMovieRequest) (*pb.DeleteMovieResponse, error) {
_, err := s.PrismaClient.Movie.FindUnique(
db.Movie.ID.Equals(req.Id),
).Delete().Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to delete movie: %v", err)
}
return &pb.DeleteMovieResponse{
Success: true,
}, nil
}
// ListScreenings implements the ListScreenings RPC method.
func (s *CinemaServiceServer) ListScreenings(ctx context.Context, req *pb.ListScreeningsRequest) (*pb.ListScreeningsResponse, error) {
pageSize := int(req.PageSize)
if pageSize <= 0 {
pageSize = 10
}
page := int(req.Page)
if page <= 0 {
page = 1
}
skip := (page - 1) * pageSize
var conditions []db.ScreeningWhereParam
if req.MovieId != "" {
conditions = append(conditions, db.Screening.MovieID.Equals(req.MovieId))
}
if req.Date != "" {
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "Invalid date format: %v", err)
}
nextDay := date.Add(24 * time.Hour)
conditions = append(conditions,
db.Screening.StartTime.Gte(date),
db.Screening.StartTime.Lt(nextDay),
)
}
screenings, err := s.PrismaClient.Screening.FindMany(conditions...).
OrderBy(db.Screening.StartTime.Order(db.ASC)).
Skip(skip).
Take(pageSize).
Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to fetch screenings: %v", err)
}
totalScreenings, err := s.PrismaClient.Screening.FindMany(conditions...).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to fetch screenings: %v", err)
}
total := len(totalScreenings)
pbScreenings := make([]*pb.Screening, len(screenings))
for i, screening := range screenings {
pbScreenings[i] = &pb.Screening{
Id: screening.ID,
MovieId: screening.MovieID,
ScreenNumber: screening.ScreenNumber,
StartTime: screening.StartTime.Format(time.RFC3339),
AvailableSeats: int32(screening.AvailableSeats),
}
}
return &pb.ListScreeningsResponse{
Screenings: pbScreenings,
Total: int32(total),
}, nil
}
// CreateScreening implements the CreateScreening RPC method.
func (s *CinemaServiceServer) CreateScreening(ctx context.Context, req *pb.CreateScreeningRequest) (*pb.Screening, error) {
startTime, err := time.Parse(time.RFC3339, req.StartTime)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "Invalid start time format: %v", err)
}
// Verify movie exists before creating a screening
_, err = s.PrismaClient.Movie.FindUnique(
db.Movie.ID.Equals(req.MovieId),
).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.NotFound, "Movie not found: %v", err)
}
screening, err := s.PrismaClient.Screening.CreateOne(
db.Screening.Movie.Link(
db.Movie.ID.Equals(req.MovieId),
),
db.Screening.ScreenNumber.Set(req.ScreenNumber),
db.Screening.StartTime.Set(startTime),
db.Screening.AvailableSeats.Set(int(req.AvailableSeats)),
).Exec(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "Failed to create screening: %v", err)
}
return &pb.Screening{
Id: screening.ID,
MovieId: screening.MovieID,
ScreenNumber: screening.ScreenNumber,
StartTime: screening.StartTime.Format(time.RFC3339),
AvailableSeats: int32(screening.AvailableSeats),
}, nil
}
Step 7: Testing the API
Use these example curl commands to test your new Cinema API endpoints:
List Movies
curl -k --http2 -X GET https://localhost:8080/v1/cinema/movies \
-H "Authorization: your_token_here"
Create a New Movie
curl -k --http2 -X POST https://localhost:8080/v1/cinema/movies \
-H "Content-Type: application/json" \
-H "Authorization: your_token_here" \
-d '{
"title": "The Matrix",
"description": "A computer programmer discovers a mysterious world...",
"duration": 136,
"genre": "Sci-Fi",
"release_date": "1999-03-31"
}'
Get Movie Details
curl -k --http2 -X GET https://localhost:8080/v1/cinema/movies/{movie_id} \
-H "Authorization: your_token_here"
Create a Screening
curl -k --http2 -X POST https://localhost:8080/v1/cinema/screenings \
-H "Content-Type: application/json" \
-H "Authorization: your_token_here" \
-d '{
"movie_id": "your_movie_id",
"screen_number": "SCREEN-1",
"start_time": "2025-03-17T15:00:00Z",
"available_seats": 100
}'
List Screenings
curl -k --http2 -X GET "https://localhost:8080/v1/cinema/screenings?movie_id=your_movie_id&date=2025-03-17" \
-H "Authorization: your_token_here"
Step 8: Running and Deployment
To run the API service locally, start the Thunder application:
go run cmd/app/server/main.go
The API service will be available at:
- gRPC port: 50051
- HTTP (gRPC-Gateway) port: 8080
When ready to deploy, use Thunder’s built-in Docker and Kubernetes deployment commands:
thunder docker
thunder deploy
Best Practices
- Authentication: Secure endpoints by integrating Thunder's pre-configured authentication middleware.
- Validation: Ensure robust input validation for requests.
- Error Handling: Use gRPC error statuses to provide clear error messages.
- Logging: Leverage structured logging for monitoring and debugging.
- Testing: Develop unit tests and integration tests to validate service functionalities.
For more information, refer to the official Thunder documentation.
Happy coding!