diff --git a/internal/apierror/api_error.go b/internal/apierror/api_error.go new file mode 100644 index 0000000..003e016 --- /dev/null +++ b/internal/apierror/api_error.go @@ -0,0 +1,17 @@ +package apierror + +import "fmt" + +type APIError struct { + StatusCode int `json:"-"` + Code string `json:"code"` + Message string `json:"error"` +} + +func New(status int, code, message string) *APIError { + return &APIError{status, code, message} +} + +func (err *APIError) Error() string { + return fmt.Sprintf("%s: %s", err.Code, err.Message) +} diff --git a/internal/template/errors.go b/internal/template/errors.go new file mode 100644 index 0000000..a61c397 --- /dev/null +++ b/internal/template/errors.go @@ -0,0 +1,9 @@ +package template + +type errBadTemplate struct { + message string +} + +func (err *errBadTemplate) Error() string { + return err.message +} diff --git a/internal/template/http_handlers.go b/internal/template/http_handlers.go index c08007c..d0c5c4e 100644 --- a/internal/template/http_handlers.go +++ b/internal/template/http_handlers.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" "io" "net/http" + "tesseract/internal/apierror" "tesseract/internal/service" ) @@ -136,6 +137,10 @@ func buildTemplate(c echo.Context, body postTemplateRequestBody) error { buildArgs: body.BuildArgs, }) if err != nil { + var errBadTemplate *errBadTemplate + if errors.As(err, &errBadTemplate) { + return apierror.New(http.StatusBadRequest, "BAD_TEMPLATE", errBadTemplate.message) + } return err } diff --git a/internal/template/template_manager.go b/internal/template/template_manager.go index 7421418..b7181ee 100644 --- a/internal/template/template_manager.go +++ b/internal/template/template_manager.go @@ -11,8 +11,10 @@ import ( "fmt" "github.com/docker/docker/api/types" "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" "github.com/google/uuid" "github.com/uptrace/bun" + "strings" "time" ) @@ -252,6 +254,14 @@ func (mgr *templateManager) buildTemplate(ctx context.Context, template *templat BuildArgs: opts.buildArgs, }) if err != nil { + if errdefs.IsInvalidParameter(err) { + return nil, &errBadTemplate{ + // the docker sdk returns an error message that looks like: + // "Error response from daemon: dockerfile parse error on line 1: unknown instruction: FR (did you mean FROM?)" + // we don't want the "error response..." part because it is meaningless + message: strings.Replace(err.Error(), "Error response from daemon: ", "", 1), + } + } return nil, err } diff --git a/main.go b/main.go index 9d82856..87e6a7e 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "tesseract/internal/apierror" "tesseract/internal/migration" "tesseract/internal/service" "tesseract/internal/template" @@ -86,10 +87,20 @@ func main() { c.Logger().Error(err) _ = c.NoContent(http.StatusInternalServerError) } - } else { - c.Logger().Error(err) - _ = c.NoContent(http.StatusInternalServerError) + return } + + var apiErr *apierror.APIError + if errors.As(err, &apiErr) { + if err = c.JSON(apiErr.StatusCode, apiErr); err != nil { + c.Logger().Error(err) + _ = c.NoContent(http.StatusInternalServerError) + } + return + } + + c.Logger().Error(err) + _ = c.NoContent(http.StatusInternalServerError) } apiServer.Logger.Fatal(apiServer.Start(":8080")) diff --git a/web/src/api.ts b/web/src/api.ts index 1f23303..1ff2fe0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,12 @@ import { promiseOrThrow } from "./lib/errors"; +interface ApiErrorResponse { + code: string; + error: string; +} + +const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE"; + enum ApiError { NotFound = "NOT_FOUND", BadRequest = "BAD_REQUEST", @@ -16,10 +23,9 @@ async function fetchApi( () => ApiError.Network, ); if (res.status !== 200) { - console.log(res.status); switch (res.status) { - case 401: - throw ApiError.BadRequest; + case 400: + throw await res.json(); case 404: throw ApiError.NotFound; default: @@ -29,4 +35,15 @@ async function fetchApi( return res; } -export { ApiError, fetchApi }; +function isApiErrorResponse(error: unknown): error is ApiErrorResponse { + return ( + error !== null && + error !== undefined && + typeof error === "object" && + "code" in error && + "error" in error + ); +} + +export { API_ERROR_BAD_TEMPLATE, ApiError, fetchApi, isApiErrorResponse }; +export type { ApiErrorResponse }; diff --git a/web/src/templates/api.ts b/web/src/templates/api.ts index 751f2c7..1ffed05 100644 --- a/web/src/templates/api.ts +++ b/web/src/templates/api.ts @@ -144,6 +144,10 @@ async function buildTemplate({ Accept: "text/event-stream", }, }); + if (res.status !== 200) { + const errBody = await res.json(); + throw errBody; + } const stream = res.body?.pipeThrough(new TextDecoderStream()).getReader(); if (stream) { while (true) { diff --git a/web/src/templates/template-editor-store.tsx b/web/src/templates/template-editor-store.tsx index 28b5feb..bcc96fa 100644 --- a/web/src/templates/template-editor-store.tsx +++ b/web/src/templates/template-editor-store.tsx @@ -2,6 +2,7 @@ import { createStore, useStore } from "zustand"; import type { Template } from "./types"; import { createContext, useContext } from "react"; import { buildTemplate } from "./api"; +import { isApiErrorResponse, type ApiErrorResponse } from "@/api"; interface TemplateEditorState { template: Template; @@ -9,6 +10,7 @@ interface TemplateEditorState { isBuildInProgress: boolean; isBuildOutputVisible: boolean; buildOutput: string; + buildError: ApiErrorResponse | null; startBuild: ({ imageTag, @@ -34,6 +36,7 @@ function createTemplateEditorStore({ isBuildInProgress: false, isBuildOutputVisible: false, buildOutput: "", + buildError: null, startBuild: async ({ imageTag, buildArgs }) => { const state = get(); @@ -42,6 +45,7 @@ function createTemplateEditorStore({ isBuildInProgress: true, isBuildOutputVisible: true, buildOutput: "", + buildError: null, }); try { @@ -51,8 +55,12 @@ function createTemplateEditorStore({ templateName: state.template.name, onBuildOutput: state.addBuildOutputChunk, }); - } catch { - // TODO: handle build error + } catch (error) { + console.error(error); + if (isApiErrorResponse(error)) { + console.log("askdjskdjk"); + set({ buildError: error }); + } } finally { set({ isBuildInProgress: false }); } diff --git a/web/src/templates/template-editor.tsx b/web/src/templates/template-editor.tsx index 0b430c9..b866f69 100644 --- a/web/src/templates/template-editor.tsx +++ b/web/src/templates/template-editor.tsx @@ -1,4 +1,4 @@ -import { ApiError } from "@/api"; +import { API_ERROR_BAD_TEMPLATE, ApiError } from "@/api"; import { CodeMirrorEditor } from "@/components/codemirror-editor"; import { Button } from "@/components/ui/button.tsx"; import { Dialog, DialogTrigger } from "@/components/ui/dialog"; @@ -35,7 +35,7 @@ import { } from "./template-editor-store"; import type { Template } from "./types"; import { useToast } from "@/hooks/use-toast"; -import { ToastAction } from "@/components/ui/toast"; +import { Toaster } from "@/components/ui/toaster"; function TemplateEditor() { const { templateName, _splat } = templateEditorRoute.useParams(); @@ -122,6 +122,8 @@ function _TemplateEditor({ + + ); @@ -332,4 +334,33 @@ function BuildOutput() { ); } +function BuildErrorToast() { + const buildError = useTemplateEditorStore((state) => state.buildError); + const { toast } = useToast(); + + useEffect(() => { + if (!buildError) return; + + switch (buildError.code) { + case API_ERROR_BAD_TEMPLATE: + toast({ + variant: "destructive", + title: "Invalid template", + description: buildError.error, + }); + break; + + default: + toast({ + variant: "destructive", + title: "Unexpected error", + description: buildError.error, + }); + break; + } + }, [buildError, toast]); + + return false; +} + export { TemplateEditor };