From abf2db27989cec796d7341d5f2245c9da2014e33 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 11 Mar 2026 15:50:38 +0100 Subject: [PATCH] feat: go greenlight api wip --- .../channel-directions/channel-directions.go | 20 ++++ projects/greenlight/.DS_Store | Bin 0 -> 6148 bytes projects/greenlight/Makefile | 0 projects/greenlight/cmd/api/errors.go | 59 ++++++++++ projects/greenlight/cmd/api/healthcheck.go | 19 ++++ projects/greenlight/cmd/api/helpers.go | 104 ++++++++++++++++++ projects/greenlight/cmd/api/main.go | 61 ++++++++++ projects/greenlight/cmd/api/movies.go | 55 +++++++++ projects/greenlight/cmd/api/routes.go | 26 +++++ projects/greenlight/go.mod | 5 + projects/greenlight/go.sum | 2 + .../greenlight/greenlight-bruno/.gitignore | 9 ++ .../greenlight-bruno/Create Movie.yml | 21 ++++ .../greenlight-bruno/Get Single Movie.yml | 15 +++ .../greenlight-bruno/Healthcheck.yml | 15 +++ .../environments/Development.yml | 8 ++ .../greenlight-bruno/opencollection.yml | 10 ++ projects/greenlight/internal/data/movies.go | 15 +++ projects/greenlight/internal/data/runtime.go | 25 +++++ 19 files changed, 469 insertions(+) create mode 100644 go-by-example/channel-directions/channel-directions.go create mode 100644 projects/greenlight/.DS_Store create mode 100644 projects/greenlight/Makefile create mode 100644 projects/greenlight/cmd/api/errors.go create mode 100644 projects/greenlight/cmd/api/healthcheck.go create mode 100644 projects/greenlight/cmd/api/helpers.go create mode 100644 projects/greenlight/cmd/api/main.go create mode 100644 projects/greenlight/cmd/api/movies.go create mode 100644 projects/greenlight/cmd/api/routes.go create mode 100644 projects/greenlight/go.mod create mode 100644 projects/greenlight/go.sum create mode 100644 projects/greenlight/greenlight-bruno/.gitignore create mode 100644 projects/greenlight/greenlight-bruno/Create Movie.yml create mode 100644 projects/greenlight/greenlight-bruno/Get Single Movie.yml create mode 100644 projects/greenlight/greenlight-bruno/Healthcheck.yml create mode 100644 projects/greenlight/greenlight-bruno/environments/Development.yml create mode 100644 projects/greenlight/greenlight-bruno/opencollection.yml create mode 100644 projects/greenlight/internal/data/movies.go create mode 100644 projects/greenlight/internal/data/runtime.go diff --git a/go-by-example/channel-directions/channel-directions.go b/go-by-example/channel-directions/channel-directions.go new file mode 100644 index 0000000..7006fe2 --- /dev/null +++ b/go-by-example/channel-directions/channel-directions.go @@ -0,0 +1,20 @@ +package main + +import "fmt" + +func ping(pings chan<- string, msg string) { + pings <- msg +} + +func pong(pings <-chan string, pongs chan<- string) { + msg := <-pings + pongs <- msg +} + +func main() { + pings := make(chan string, 1) + pongs := make(chan string, 1) + ping(pings, "passed message") + pong(pings, pongs) + fmt.Println(<-pongs) +} diff --git a/projects/greenlight/.DS_Store b/projects/greenlight/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a17f3769f2d6544966d7451abe018f02f0976406 GIT binary patch literal 6148 zcmeHK%}T>S5T0$TO({YS3OxqAR*ZiI@e*o%0V8@)sR=1))(?gd>&_Z zw_>T{MMTO>nf+#GXLi|d%T5*mM0XPH0oVXwp%PXa*nA;0PP!x&;~^AkjuaAzVF)A0 zm!jG69~q#tv+=|-7~t>o`Rzc6-U<3-Sd1UTM1~mq8a^iRICtGQQK{C}*6UW?YFfAc zNEUt`$aW4jyr2R)8l=z4?441!`j;3Jvr~+Cl9H7*5nHOA1c`}IENQBe%$Ai zpQVXRhZsAbXP(c<3@`)Cz$!6d?l`NtN}J-nFaylMuP{LOgM&)w87wubs{_mLPNtdIn34ID#T{Dxyvm=7}M6I{FU0olWt_*XEX)f-oJdZpqZvrOi>Tbx`k6Nhq$=_>qE! iD#aK}rMQKv1pN*fh@QbxBYIHyLqO5M4Kwhk47>wt&Qf~- literal 0 HcmV?d00001 diff --git a/projects/greenlight/Makefile b/projects/greenlight/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/projects/greenlight/cmd/api/errors.go b/projects/greenlight/cmd/api/errors.go new file mode 100644 index 0000000..1621120 --- /dev/null +++ b/projects/greenlight/cmd/api/errors.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "net/http" +) + +// The logError() method is a generic helper for logging an error message. “Later in the +// book we'll upgrade this to use structured logging, and record additional information +// about the request including the HTTP method and URL. +func (app *application) logError(r *http.Request, err error) { + app.logger.Println(err) +} + +// The errorResponse() method is a generic helper for sending JSON-formatted error +// messages to the client with a given status code. Note that we're using an interface{} +// type for the message parameter, rather than just a string type, as this gives us +// more flexibility over the values that we can include in the response. +func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) { + env := envelope{"error": message} + + // Write the response using the writeJSON() helper. If this happens to return an + // error then log it, and fall back to sending the client an empty response with a + // 500 Internal Server Error status code. + err := app.writeJSON(w, status, env, nil) + if err != nil { + app.logError(r, err) + w.WriteHeader(500) + } +} + +// The serverErrorResponse() method will be used when our application encounters an +// unexpected problem at runtime. It logs the detailed error message, then uses the +// errorResponse() helper to send a 500 Internal Server Error status code and JSON +// response (containing a generic error message) to the client. +func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + app.logError(r, err) + + message := "the server encountered a problem and could not process your request" + app.errorResponse(w, r, http.StatusInternalServerError, message) +} + +// The notFoundResponse() method will be used to send a 404 Not Found status code and +// JSON response to the client. +func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { + message := "the requested resource could not be found" + app.errorResponse(w, r, http.StatusNotFound, message) +} + +// The methodNotAllowedResponse() method will be used to send a 405 Method Not Allowed +// status code and JSON response to the client. +func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + message := fmt.Sprintf("the %s method is not supported for this resource", r.Method) + app.errorResponse(w, r, http.StatusMethodNotAllowed, message) +} + +func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + app.errorResponse(w, r, http.StatusBadRequest, err.Error()) +} diff --git a/projects/greenlight/cmd/api/healthcheck.go b/projects/greenlight/cmd/api/healthcheck.go new file mode 100644 index 0000000..4067909 --- /dev/null +++ b/projects/greenlight/cmd/api/healthcheck.go @@ -0,0 +1,19 @@ +package main + +import ( + "net/http" +) + +func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) { + env := envelope{ + "status": "available", + "environment": app.config.env, + "version": version, + } + + err := app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + // Use the new serverErrorResponse() helper. + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/cmd/api/helpers.go b/projects/greenlight/cmd/api/helpers.go new file mode 100644 index 0000000..df0f2d7 --- /dev/null +++ b/projects/greenlight/cmd/api/helpers.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "strconv" + + "github.com/julienschmidt/httprouter" +) + +// Define an envelope type. +type envelope map[string]any + +// Retrieve the "id" URL parameter from the current request context, then convert it to +// an integer and return it. If the operation isn't successful, return 0 and an error. +func (app *application) readIDParam(r *http.Request) (int64, error) { + params := httprouter.ParamsFromContext(r.Context()) + + id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + if err != nil || id < 1 { + return 0, errors.New("invalid id parameter") + } + + return id, nil +} + +// Change the data parameter to have the type envelope instead of interface{}. +func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { + js, err := json.MarshalIndent(data, "", "\t") + if err != nil { + return err + } + + js = append(js, '\n') + + maps.Copy(w.Header(), headers) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + + return nil +} + +func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { + // Decode the request body into the target destination. + err := json.NewDecoder(r.Body).Decode(dst) + if err != nil { + // If there is an error during decoding, start the triage... + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + + switch { + // Use the errors.As() function to check whether the error has the type + + // *json.SyntaxError. If it does, then return a plain-english error message + // which includes the location of the problem. + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + + // In some circumstances Decode() may also return an io.ErrUnexpectedEOF error + // for syntax errors in the JSON. So we check for this using errors.Is() and + // return a generic error message. There is an open issue regarding this at + // https://github.com/golang/go/issues/25956. + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + + // Likewise, catch any *json.UnmarshalTypeError errors. These occur when the + // JSON value is the wrong type for the target destination. If the error relates + // to a specific field, then we include that in our error message to make it + // easier for the client to debug. + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) + } + return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) + + // An io.EOF error will be returned by Decode() if the request body is empty. We + // check for this with errors.Is() and return a plain-english error message + // instead. + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + + // A json.InvalidUnmarshalError error will be returned if we pass a non-nil + // pointer to Decode(). We catch this and panic, rather than returning an error + // to our handler. At the end of this chapter we'll talk about panicking + // versus returning errors, and discuss why it's an appropriate thing to do in + // this specific situation. + case errors.As(err, &invalidUnmarshalError): + panic(err) + + // For anything else, return the error message as-is. + default: + return err + } + } + + return nil +} diff --git a/projects/greenlight/cmd/api/main.go b/projects/greenlight/cmd/api/main.go new file mode 100644 index 0000000..55a4ec8 --- /dev/null +++ b/projects/greenlight/cmd/api/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "time" +) + +// Declare a string containing the application version number. Later in the book we'll +// generate this automatically at build time, but for now we'll just store the version +// number as a hard-coded global constant. +const version = "1.0.0" + +// Define a config struct to hold all the configuration settings for our application. +// For now, the only configuration settings will be the network port that we want the +// server to listen on, and the name of the current operating environment for the +// application (development, staging, production, etc.). We will read in these +// configuration settings from command-line flags when the application starts. +type config struct { + port int + env string +} + +// Define an application struct to hold the dependencies for our HTTP handlers, helpers, +// and middleware. At the moment this only contains a copy of the config struct and a +// logger, but it will grow to include a lot more as our build progresses. +type application struct { + config config + logger *log.Logger +} + +func main() { + var cfg config + + flag.IntVar(&cfg.port, "port", 4000, "API server port") + flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") + flag.Parse() + + logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) + + app := &application{ + config: cfg, + logger: logger, + } + + // Use the httprouter instance returned by app.routes() as the server handler. + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.port), + Handler: app.routes(), + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + logger.Printf("starting %s server on %s", cfg.env, srv.Addr) + err := srv.ListenAndServe() + logger.Fatal(err) +} diff --git a/projects/greenlight/cmd/api/movies.go b/projects/greenlight/cmd/api/movies.go new file mode 100644 index 0000000..d0da4da --- /dev/null +++ b/projects/greenlight/cmd/api/movies.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "net/http" + "time" + + "greenlight.debuggingjon.dev/internal/data" +) + +func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Title string `json:"title"` + Year int32 `json:"year"` + Runtime int32 `json:"runtime"` + Genres []string `json:"genres"` + } + + // Use the new readJSON() helper to decode the request body into the input struct. + // If this returns an error we send the client the error message along with a 400 + // Bad Request status code, just like before. + err := app.readJSON(w, r, &input) + if err != nil { + // Use the new badRequestResponse() helper. + app.badRequestResponse(w, r, err) + return + } + + fmt.Fprintf(w, "%+v\n", input) +} + +func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + // Use the new notFoundResponse() helper. + app.notFoundResponse(w, r) + return + } + + movie := data.Movie{ + ID: id, + CreatedAt: time.Now(), + Title: "Casablanca", + Runtime: 102, + Genres: []string{"drama", "romance", "war"}, Version: 1, + } + + // Create an envelope{"movie": movie} instance and pass it to writeJSON(), instead + // of passing the plain movie struct. + err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) + if err != nil { + // Use the new serverErrorResponse() helper. + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go new file mode 100644 index 0000000..852d63b --- /dev/null +++ b/projects/greenlight/cmd/api/routes.go @@ -0,0 +1,26 @@ +package main + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" +) + +func (app *application) routes() *httprouter.Router { + router := httprouter.New() + + // Convert the notFoundResponse() helper to a http.Handler using the + // http.HandlerFunc() adapter, and then set it as the custom error handler for 404 + // Not Found responses. + router.NotFound = http.HandlerFunc(app.notFoundResponse) + + // Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set + // it as the custom error handler for 405 Method Not Allowed responses. + router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) + + router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) + router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler) + router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler) + + return router +} diff --git a/projects/greenlight/go.mod b/projects/greenlight/go.mod new file mode 100644 index 0000000..ff85ef2 --- /dev/null +++ b/projects/greenlight/go.mod @@ -0,0 +1,5 @@ +module greenlight.debuggingjon.dev + +go 1.25.0 + +require github.com/julienschmidt/httprouter v1.3.0 // indirect diff --git a/projects/greenlight/go.sum b/projects/greenlight/go.sum new file mode 100644 index 0000000..096c54e --- /dev/null +++ b/projects/greenlight/go.sum @@ -0,0 +1,2 @@ +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= diff --git a/projects/greenlight/greenlight-bruno/.gitignore b/projects/greenlight/greenlight-bruno/.gitignore new file mode 100644 index 0000000..e19311f --- /dev/null +++ b/projects/greenlight/greenlight-bruno/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/projects/greenlight/greenlight-bruno/Create Movie.yml b/projects/greenlight/greenlight-bruno/Create Movie.yml new file mode 100644 index 0000000..afc38b4 --- /dev/null +++ b/projects/greenlight/greenlight-bruno/Create Movie.yml @@ -0,0 +1,21 @@ +info: + name: Create Movie + type: http + seq: 2 + +http: + method: POST + url: "{{URL}}{{VERSION}}/movies" + body: + type: json + data: |- + { + "title": "Moana" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/greenlight-bruno/Get Single Movie.yml b/projects/greenlight/greenlight-bruno/Get Single Movie.yml new file mode 100644 index 0000000..471cd52 --- /dev/null +++ b/projects/greenlight/greenlight-bruno/Get Single Movie.yml @@ -0,0 +1,15 @@ +info: + name: Get Single Movie + type: http + seq: 3 + +http: + method: GET + url: "{{URL}}{{VERSION}}/movies/{{MOVIE_ID}}" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/greenlight-bruno/Healthcheck.yml b/projects/greenlight/greenlight-bruno/Healthcheck.yml new file mode 100644 index 0000000..3ad92d3 --- /dev/null +++ b/projects/greenlight/greenlight-bruno/Healthcheck.yml @@ -0,0 +1,15 @@ +info: + name: Healthcheck + type: http + seq: 1 + +http: + method: GET + url: "{{URL}}{{VERSION}}/healthcheck" + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/greenlight-bruno/environments/Development.yml b/projects/greenlight/greenlight-bruno/environments/Development.yml new file mode 100644 index 0000000..fdeaeb1 --- /dev/null +++ b/projects/greenlight/greenlight-bruno/environments/Development.yml @@ -0,0 +1,8 @@ +name: Development +variables: + - name: URL + value: http://localhost:4000/ + - name: VERSION + value: v1 + - name: MOVIE_ID + value: "1" diff --git a/projects/greenlight/greenlight-bruno/opencollection.yml b/projects/greenlight/greenlight-bruno/opencollection.yml new file mode 100644 index 0000000..db6540a --- /dev/null +++ b/projects/greenlight/greenlight-bruno/opencollection.yml @@ -0,0 +1,10 @@ +opencollection: 1.0.0 + +info: + name: greenlight-bruno +bundled: false +extensions: + bruno: + ignore: + - node_modules + - .git diff --git a/projects/greenlight/internal/data/movies.go b/projects/greenlight/internal/data/movies.go new file mode 100644 index 0000000..ed81785 --- /dev/null +++ b/projects/greenlight/internal/data/movies.go @@ -0,0 +1,15 @@ +package data + +import ( + "time" +) + +type Movie struct { + ID int64 `json:"id"` + CreatedAt time.Time `json:"-"` // Use the - directive + Title string `json:"title"` + Year int32 `json:"year,omitempty"` // Add the omitempty directive + Runtime Runtime `json:"runtime,omitempty"` // Add the omitempty directive + Genres []string `json:"genres,omitempty"` // Add the omitempty directive + Version int32 `json:"version"` +} diff --git a/projects/greenlight/internal/data/runtime.go b/projects/greenlight/internal/data/runtime.go new file mode 100644 index 0000000..09266f6 --- /dev/null +++ b/projects/greenlight/internal/data/runtime.go @@ -0,0 +1,25 @@ +package data + +import ( + "fmt" + "strconv" +) + +// Declare a custom Runtime type, which has the underlying type int32 (the same as our +// Movie struct field). +type Runtime int32 + +// Implement a MarshalJSON() method on the Runtime type so that it satisfies the +// json.Marshaler interface. This should return the JSON-encoded value for the movie +// runtime (in our case, it will return a string in the format " mins"). +func (r Runtime) MarshalJSON() ([]byte, error) { + // Generate a string containing the movie runtime in the required format. + jsonValue := fmt.Sprintf("%d mins", r) + + // Use the strconv.Quote() function on the string to wrap it in double quotes. It + // needs to be surrounded by double quotes in order to be a valid *JSON string*. + quotedJSONValue := strconv.Quote(jsonValue) + + // Convert the quoted string value to a byte slice and return it. + return []byte(quotedJSONValue), nil +}