diff --git a/Makefile b/Makefile index 044be022..a8d0ddd0 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ YELLOW := \033[1;33m # -------------------------------------------------------------------------------------------------------------------- # # -------------------------------------------------------------------------------------------------------------------- # -ROOT_NETWORK := gocanto +ROOT_NETWORK := oullin_net DATABASE := postgres SOURCE := go_bindata ROOT_PATH := $(shell pwd) diff --git a/boost/router.go b/boost/router.go index b280b9d4..7d695df9 100644 --- a/boost/router.go +++ b/boost/router.go @@ -29,7 +29,7 @@ func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc { } func (r *Router) Profile() { - abstract := handler.MakeProfileHandler() + abstract := handler.MakeProfileHandler("./storage/fixture/profile.json") resolver := r.PipelineFor( abstract.Handle, @@ -39,7 +39,7 @@ func (r *Router) Profile() { } func (r *Router) Experience() { - abstract := handler.MakeExperienceHandler() + abstract := handler.MakeExperienceHandler("./storage/fixture/experience.json") resolver := r.PipelineFor( abstract.Handle, @@ -49,7 +49,7 @@ func (r *Router) Experience() { } func (r *Router) Projects() { - abstract := handler.MakeProjectsHandler() + abstract := handler.MakeProjectsHandler("./storage/fixture/projects.json") resolver := r.PipelineFor( abstract.Handle, @@ -59,7 +59,7 @@ func (r *Router) Projects() { } func (r *Router) Social() { - abstract := handler.MakeSocialHandler() + abstract := handler.MakeSocialHandler("./storage/fixture/social.json") resolver := r.PipelineFor( abstract.Handle, @@ -69,7 +69,7 @@ func (r *Router) Social() { } func (r *Router) Talks() { - abstract := handler.MakeTalks() + abstract := handler.MakeTalksHandler("./storage/fixture/talks.json") resolver := r.PipelineFor( abstract.Handle, diff --git a/handler/experience.go b/handler/experience.go index 028f893d..8b9acc7b 100644 --- a/handler/experience.go +++ b/handler/experience.go @@ -1,33 +1,44 @@ package handler import ( + "github.com/oullin/handler/payload" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" - "os" ) type ExperienceHandler struct { - content string + filePath string } -func MakeExperienceHandler() ExperienceHandler { +func MakeExperienceHandler(filePath string) ExperienceHandler { return ExperienceHandler{ - content: "./storage/fixture/experience.json", + filePath: filePath, } } func (h ExperienceHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - fixture, err := os.ReadFile(h.content) + data, err := pkg.ParseJsonFile[payload.ExperienceResponse](h.filePath) if err != nil { - slog.Error("Error reading projects file", "error", err) + slog.Error("Error reading experience file", "error", err) return http.InternalError("could not read experience data") } - if err := writeJSON(fixture, w); err != nil { - return http.InternalError(err.Error()) + resp := http.MakeResponseFrom(data.Version, w, r) + + if resp.HasCache() { + resp.RespondWithNotModified() + + return nil + } + + if err := resp.RespondOk(data); err != nil { + slog.Error("Error marshaling JSON for experience response", "error", err) + + return nil } return nil // A nil return indicates success. diff --git a/handler/payload/experience.go b/handler/payload/experience.go new file mode 100644 index 00000000..87bffd4f --- /dev/null +++ b/handler/payload/experience.go @@ -0,0 +1,20 @@ +package payload + +type ExperienceResponse struct { + Version string `json:"version"` + Data []ExperienceData `json:"data"` +} + +type ExperienceData struct { + UUID string `json:"uuid"` + Company string `json:"company"` + EmploymentType string `json:"employment_type"` + LocationType string `json:"location_type"` + Position string `json:"position"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + Summary string `json:"summary"` + Country string `json:"country"` + City string `json:"city"` + Skills string `json:"skills"` +} diff --git a/handler/payload/profile.go b/handler/payload/profile.go new file mode 100644 index 00000000..e1eb263d --- /dev/null +++ b/handler/payload/profile.go @@ -0,0 +1,14 @@ +package payload + +type ProfileResponse struct { + Version string `json:"version"` + Data ProfileData `json:"data"` +} + +type ProfileData struct { + Nickname string `json:"nickname"` + Handle string `json:"handle"` + Name string `json:"name"` + Email string `json:"email"` + Profession string `json:"profession"` +} diff --git a/handler/payload/projects.go b/handler/payload/projects.go new file mode 100644 index 00000000..6b4ec530 --- /dev/null +++ b/handler/payload/projects.go @@ -0,0 +1,16 @@ +package payload + +type ProjectsResponse struct { + Version string `json:"version"` + Data []ProjectsData `json:"data"` +} + +type ProjectsData struct { + UUID string `json:"uuid"` + Language string `json:"language"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/handler/payload/social.go b/handler/payload/social.go new file mode 100644 index 00000000..8623a9fe --- /dev/null +++ b/handler/payload/social.go @@ -0,0 +1,14 @@ +package payload + +type SocialResponse struct { + Version string `json:"version"` + Data []SocialData `json:"data"` +} + +type SocialData struct { + UUID string `json:"uuid"` + Handle string `json:"handle"` + URL string `json:"url"` + Description string `json:"description"` + Name string `json:"name"` +} diff --git a/handler/payload/talks.go b/handler/payload/talks.go new file mode 100644 index 00000000..eeca08b9 --- /dev/null +++ b/handler/payload/talks.go @@ -0,0 +1,17 @@ +package payload + +type TalksResponse struct { + Version string `json:"version"` + Data []TalksData `json:"data"` +} + +type TalksData struct { + UUID string `json:"uuid"` + Title string `json:"title"` + Subject string `json:"subject"` + Location string `json:"location"` + URL string `json:"url"` + Photo string `json:"photo"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/handler/profile.go b/handler/profile.go index c7613c39..bca0bbe9 100644 --- a/handler/profile.go +++ b/handler/profile.go @@ -1,33 +1,44 @@ package handler import ( + "github.com/oullin/handler/payload" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" - "os" ) type ProfileHandler struct { - content string + filePath string } -func MakeProfileHandler() ProfileHandler { +func MakeProfileHandler(filePath string) ProfileHandler { return ProfileHandler{ - content: "./storage/fixture/profile.json", + filePath: filePath, } } func (h ProfileHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - fixture, err := os.ReadFile(h.content) + data, err := pkg.ParseJsonFile[payload.ProfileResponse](h.filePath) if err != nil { - slog.Error("Error reading projects file", "error", err) + slog.Error("Error reading profile file", "error", err) return http.InternalError("could not read profile data") } - if err := writeJSON(fixture, w); err != nil { - return http.InternalError(err.Error()) + resp := http.MakeResponseFrom(data.Version, w, r) + + if resp.HasCache() { + resp.RespondWithNotModified() + + return nil + } + + if err := resp.RespondOk(data); err != nil { + slog.Error("Error marshaling JSON for profile response", "error", err) + + return nil } return nil // A nil return indicates success. diff --git a/handler/projects.go b/handler/projects.go index 2268d86c..9ea65e5b 100644 --- a/handler/projects.go +++ b/handler/projects.go @@ -1,24 +1,25 @@ package handler import ( + "github.com/oullin/handler/payload" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" - "os" ) type ProjectsHandler struct { - content string + filePath string } -func MakeProjectsHandler() ProjectsHandler { +func MakeProjectsHandler(filePath string) ProjectsHandler { return ProjectsHandler{ - content: "./storage/fixture/projects.json", + filePath: filePath, } } func (h ProjectsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - fixture, err := os.ReadFile(h.content) + data, err := pkg.ParseJsonFile[payload.ProjectsResponse](h.filePath) if err != nil { slog.Error("Error reading projects file", "error", err) @@ -26,8 +27,18 @@ func (h ProjectsHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) return http.InternalError("could not read projects data") } - if err := writeJSON(fixture, w); err != nil { - return http.InternalError(err.Error()) + resp := http.MakeResponseFrom(data.Version, w, r) + + if resp.HasCache() { + resp.RespondWithNotModified() + + return nil + } + + if err := resp.RespondOk(data); err != nil { + slog.Error("Error marshaling JSON for projects response", "error", err) + + return nil } return nil // A nil return indicates success. diff --git a/handler/response.go b/handler/response.go deleted file mode 100644 index 55f7860e..00000000 --- a/handler/response.go +++ /dev/null @@ -1,17 +0,0 @@ -package handler - -import ( - "fmt" - baseHttp "net/http" -) - -func writeJSON(content []byte, w baseHttp.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(baseHttp.StatusOK) - - if _, err := w.Write(content); err != nil { - return fmt.Errorf("error writing response: %v", err) - } - - return nil -} diff --git a/handler/social.go b/handler/social.go index 89131176..4c9e6d96 100644 --- a/handler/social.go +++ b/handler/social.go @@ -1,33 +1,44 @@ package handler import ( + "github.com/oullin/handler/payload" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" - "os" ) type SocialHandler struct { - content string + filePath string } -func MakeSocialHandler() SocialHandler { +func MakeSocialHandler(filePath string) SocialHandler { return SocialHandler{ - content: "./storage/fixture/social.json", + filePath: filePath, } } func (h SocialHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - fixture, err := os.ReadFile(h.content) + data, err := pkg.ParseJsonFile[payload.SocialResponse](h.filePath) if err != nil { - slog.Error("Error reading projects file", "error", err) + slog.Error("Error reading social file", "error", err) return http.InternalError("could not read social data") } - if err := writeJSON(fixture, w); err != nil { - return http.InternalError(err.Error()) + resp := http.MakeResponseFrom(data.Version, w, r) + + if resp.HasCache() { + resp.RespondWithNotModified() + + return nil + } + + if err := resp.RespondOk(data); err != nil { + slog.Error("Error marshaling JSON for social response", "error", err) + + return nil } return nil // A nil return indicates success. diff --git a/handler/talks.go b/handler/talks.go index b71dd0b6..de8a3e91 100644 --- a/handler/talks.go +++ b/handler/talks.go @@ -1,33 +1,44 @@ package handler import ( + "github.com/oullin/handler/payload" + "github.com/oullin/pkg" "github.com/oullin/pkg/http" "log/slog" baseHttp "net/http" - "os" ) -type Talks struct { - content string +type TalksHandler struct { + filePath string } -func MakeTalks() Talks { - return Talks{ - content: "./storage/fixture/talks.json", +func MakeTalksHandler(filePath string) TalksHandler { + return TalksHandler{ + filePath: filePath, } } -func (h Talks) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { - fixture, err := os.ReadFile(h.content) +func (h TalksHandler) Handle(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { + data, err := pkg.ParseJsonFile[payload.TalksResponse](h.filePath) if err != nil { - slog.Error("Error reading projects file", "error", err) + slog.Error("Error reading talks file", "error", err) return http.InternalError("could not read talks data") } - if err := writeJSON(fixture, w); err != nil { - return http.InternalError(err.Error()) + resp := http.MakeResponseFrom(data.Version, w, r) + + if resp.HasCache() { + resp.RespondWithNotModified() + + return nil + } + + if err := resp.RespondOk(data); err != nil { + slog.Error("Error marshaling JSON for talks response", "error", err) + + return nil } return nil // A nil return indicates success. diff --git a/pkg/handler.go b/pkg/handler.go deleted file mode 100644 index dca7eac5..00000000 --- a/pkg/handler.go +++ /dev/null @@ -1,41 +0,0 @@ -package pkg - -import ( - "encoding/json" - "github.com/oullin/pkg/response" - "log/slog" - "net/http" -) - -type BaseHandler func(w http.ResponseWriter, r *http.Request) *response.Response - -func CreateHandle(callback BaseHandler) http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - if err := callback(writer, request); err != nil { - err.Respond(writer) - return // Stop processing after error response. - } - - // If the callback returns nil, it means success and the handler. - // The caller itself is responsible for writing the success response. - } -} - -func SendJSON(writer http.ResponseWriter, statusCode int, data any) *response.Response { - writer.Header().Set("Content-Type", "application/json; charset=utf-8") - writer.WriteHeader(statusCode) - - if data == nil { - // Handle cases where no data needs to be sent (e.g., 204 No Content) - // Although usually, 204 doesn't have a body or Content-Type. - // This check prevents json.NewEncoder from writing "null". - return nil - } - - if err := json.NewEncoder(writer).Encode(data); err != nil { - slog.Error("Error encoding success response", "error", err) - return response.InternalServerError("Failed to encode response", err) - } - - return nil // Signal success -} diff --git a/pkg/http/message.go b/pkg/http/message.go deleted file mode 100644 index 85280ab8..00000000 --- a/pkg/http/message.go +++ /dev/null @@ -1,13 +0,0 @@ -package http - -import ( - "fmt" - baseHttp "net/http" -) - -func InternalError(msg string) *ApiError { - return &ApiError{ - Message: fmt.Sprintf("Internal Server Error: %s", msg), - Status: baseHttp.StatusInternalServerError, - } -} diff --git a/pkg/http/response.go b/pkg/http/response.go new file mode 100644 index 00000000..0eb8129d --- /dev/null +++ b/pkg/http/response.go @@ -0,0 +1,73 @@ +package http + +import ( + "encoding/json" + "fmt" + baseHttp "net/http" + "strings" +) + +type Response struct { + etag string + cacheControl string + writer baseHttp.ResponseWriter + request *baseHttp.Request + headers func(w baseHttp.ResponseWriter) +} + +func MakeResponseFrom(salt string, writer baseHttp.ResponseWriter, request *baseHttp.Request) *Response { + etag := fmt.Sprintf( + `"%s"`, + strings.TrimSpace(salt), + ) + + cacheControl := "public, max-age=3600" + + return &Response{ + writer: writer, + request: request, + etag: strings.TrimSpace(etag), + cacheControl: cacheControl, + headers: func(w baseHttp.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", cacheControl) + w.Header().Set("ETag", etag) + }, + } +} + +func (r *Response) WithHeaders(callback func(w baseHttp.ResponseWriter)) { + callback(r.writer) +} + +func (r *Response) RespondOk(payload any) error { + w := r.writer + headers := r.headers + + headers(w) + w.WriteHeader(baseHttp.StatusOK) + + return json.NewEncoder(r.writer).Encode(payload) +} + +func (r *Response) HasCache() bool { + request := r.request + + match := strings.TrimSpace( + request.Header.Get("If-None-Match"), + ) + + return match == r.etag +} + +func (r *Response) RespondWithNotModified() { + r.writer.WriteHeader(baseHttp.StatusNotModified) +} + +func InternalError(msg string) *ApiError { + return &ApiError{ + Message: fmt.Sprintf("Internal Server Error: %s", msg), + Status: baseHttp.StatusInternalServerError, + } +} diff --git a/pkg/parser.go b/pkg/parser.go new file mode 100644 index 00000000..ddfed34b --- /dev/null +++ b/pkg/parser.go @@ -0,0 +1,29 @@ +package pkg + +import ( + "encoding/json" + "fmt" + "os" +) + +func ParseJsonFile[T any](filePath string) (T, error) { + // We must declare a variable of type T to hold the result. + // This will also be the zero value of T if an error occurs. + var result T + + // Read the entire file into a byte slice. + content, err := os.ReadFile(filePath) + if err != nil { + // Wrap the error with context for clearer debugging. + return result, fmt.Errorf("could not read file %s: %w", filePath, err) + } + + // Unmarshal the JSON data into the 'result' variable. + // We pass a pointer to 'result' so json.Unmarshal can populate it. + if err := json.Unmarshal(content, &result); err != nil { + return result, fmt.Errorf("could not unmarshal json from %s: %w", filePath, err) + } + + // If successful, return the populated struct and a nil error. + return result, nil +} diff --git a/pkg/request/request.go b/pkg/request/request.go deleted file mode 100644 index 28c20fb3..00000000 --- a/pkg/request/request.go +++ /dev/null @@ -1,75 +0,0 @@ -package request - -import ( - "errors" - "fmt" - "github.com/oullin/pkg/media" - "io" - "log/slog" - "mime/multipart" - "net/http" -) - -type Request struct { - baseRequest *http.Request - isMultipart bool - multipartReader *multipart.Reader - multiPartRawData media.MultipartFormInterface -} - -func MakeMultipartRequest[T media.MultipartFormInterface](r *http.Request, rawData T) (*Request, error) { - reader, err := r.MultipartReader() - - if err != nil { - return nil, errors.New("the isMultipart form reader is invalid") - } - - return &Request{ - baseRequest: r, - isMultipart: true, - multiPartRawData: rawData, - multipartReader: reader, - }, nil -} - -func (req *Request) Close(message *string) { - m := "Issue closing the request body" - - if message == nil { - message = &m - } - - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - slog.Error(m, err) - } - }(req.baseRequest.Body) -} - -func (req *Request) ParseRawData(callback func(reader *multipart.Reader, data media.MultipartFormInterface) error) error { - fmt.Println(fmt.Sprintf("dd: %+v", req)) - if req.multipartReader == nil { - return errors.New("1) invalid isMultipart form") - } - - if req.multiPartRawData == nil { - return errors.New("2) invalid isMultipart form request") - } - - result := callback(req.multipartReader, req.multiPartRawData) - - if result != nil { - return errors.New("3) invalid isMultipart form parsing: " + result.Error()) - } - - return nil -} - -func (req *Request) GetFile() []byte { - return req.multiPartRawData.GetFile() -} - -func (req *Request) GetHeaderName() string { - return req.multiPartRawData.GetHeaderName() -} diff --git a/pkg/response/response.go b/pkg/response/response.go deleted file mode 100644 index 7b75bb79..00000000 --- a/pkg/response/response.go +++ /dev/null @@ -1,92 +0,0 @@ -package response - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" -) - -type Response struct { - Code int - Message string - Err error - ValidationErrors map[string]any -} - -func MakeResponse(code int, message string, err error) *Response { - return &Response{ - Code: code, - Message: message, - Err: err, - ValidationErrors: make(map[string]any), - } -} - -func BadRequest(message string, err error) *Response { - return MakeResponse(http.StatusBadRequest, message, err) -} - -func InternalServerError(message string, err error) *Response { - return MakeResponse(http.StatusInternalServerError, message, err) -} - -func Forbidden(message string, validationErrors map[string]any, err error) *Response { - return &Response{ - Code: http.StatusForbidden, - Message: message, - Err: err, - ValidationErrors: validationErrors, - } -} - -func Unauthorized(message string, err error) *Response { - return &Response{ - Code: http.StatusUnauthorized, - Message: message, - Err: err, - ValidationErrors: make(map[string]any), - } -} - -func Unprocessable(message string, err error) *Response { - return &Response{ - Code: http.StatusUnprocessableEntity, - Message: message, - Err: err, - ValidationErrors: make(map[string]any), - } -} - -func (e *Response) Error() string { - if e.Err != nil { - return fmt.Sprintf("%s: %v", e.Message, e.Err) - } - - return e.Message -} - -func (e *Response) Unwrap() error { - return e.Err -} - -func (e *Response) Respond(w http.ResponseWriter) { - slog.Error("HTTP Error", "status", e.Code, "message", e.Message, "error", e.Err, "validation_errors", e.ValidationErrors) - - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") // Basic security header - w.WriteHeader(e.Code) - - payload := map[string]any{ - "message": e.Message, - } - - if len(e.ValidationErrors) > 0 { - payload["errors"] = e.ValidationErrors - } - - if err := json.NewEncoder(w).Encode(payload); err != nil { - slog.Error("Error encoding error response", "encode_error", err, "original_error", e) - _, _ = fmt.Fprintf(w, `{"message":"Error generating error response"}`) - } -} diff --git a/storage/fixture/experience.json b/storage/fixture/experience.json index a4cfb8ff..050a2643 100644 --- a/storage/fixture/experience.json +++ b/storage/fixture/experience.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "date": [ + "data": [ { "uuid": "c17a68bc-8832-4d44-b2ed-f9587cf14cd1", "company": "Perx Technologies",