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

  1. Authentication: Secure endpoints by integrating Thunder's pre-configured authentication middleware.
  2. Validation: Ensure robust input validation for requests.
  3. Error Handling: Use gRPC error statuses to provide clear error messages.
  4. Logging: Leverage structured logging for monitoring and debugging.
  5. Testing: Develop unit tests and integration tests to validate service functionalities.

For more information, refer to the official Thunder documentation.

Happy coding!