feat: handle template conflict error

This commit is contained in:
2024-12-03 11:32:21 +00:00
parent cb8822e280
commit ee776f4c6e
7 changed files with 116 additions and 62 deletions

View File

@@ -56,36 +56,21 @@ func fetchTemplate(c echo.Context) error {
return c.JSON(http.StatusOK, template) return c.JSON(http.StatusOK, template)
} }
func createOrUpdateTemplate(c echo.Context) error {
mgr := templateManagerFrom(c)
exists, err := mgr.hasTemplate(c.Request().Context(), c.Param("templateName"))
if err != nil {
return err
}
if !exists {
return createTemplate(c)
}
var body postTemplateRequestBody
err = json.NewDecoder(c.Request().Body).Decode(&body)
if err != nil {
return err
}
if body.ImageTag != nil || body.BuildArgs != nil {
return buildTemplate(c, body)
}
return updateTemplate(c, body)
}
func createTemplate(c echo.Context) error { func createTemplate(c echo.Context) error {
mgr := templateManagerFrom(c)
name := c.Param("templateName") name := c.Param("templateName")
mgr := templateManagerFrom(c)
exists, err := mgr.hasTemplate(c.Request().Context(), name)
if err != nil {
return err
}
if exists {
return echo.NewHTTPError(http.StatusConflict)
}
var body createTemplateRequestBody var body createTemplateRequestBody
err := json.NewDecoder(c.Request().Body).Decode(&body) if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
if err != nil {
return err return err
} }
@@ -101,6 +86,29 @@ func createTemplate(c echo.Context) error {
return c.JSON(http.StatusOK, createdTemplate) return c.JSON(http.StatusOK, createdTemplate)
} }
func updateOrBuildTemplate(c echo.Context) error {
mgr := templateManagerFrom(c)
exists, err := mgr.hasTemplate(c.Request().Context(), c.Param("templateName"))
if err != nil {
return err
}
if !exists {
return echo.NewHTTPError(http.StatusNotFound)
}
var body postTemplateRequestBody
err = json.NewDecoder(c.Request().Body).Decode(&body)
if err != nil {
return err
}
if body.ImageTag != nil || body.BuildArgs != nil {
return buildTemplate(c, body)
}
return updateTemplate(c, body)
}
func updateTemplate(c echo.Context, body postTemplateRequestBody) error { func updateTemplate(c echo.Context, body postTemplateRequestBody) error {
name := c.Param("templateName") name := c.Param("templateName")
mgr := templateManagerFrom(c) mgr := templateManagerFrom(c)

View File

@@ -9,7 +9,8 @@ func DefineRoutes(g *echo.Group, services service.Services) {
g.Use(newTemplateManagerMiddleware(services)) g.Use(newTemplateManagerMiddleware(services))
g.GET("/templates", fetchAllTemplates) g.GET("/templates", fetchAllTemplates)
g.GET("/templates/:templateName", fetchTemplate, validateTemplateName) g.GET("/templates/:templateName", fetchTemplate, validateTemplateName)
g.POST("/templates/:templateName", createOrUpdateTemplate, validateTemplateName) g.PUT("/templates/:templateName", createTemplate, validateTemplateName)
g.POST("/templates/:templateName", updateOrBuildTemplate, validateTemplateName)
g.DELETE("/templates/:templateName", deleteTemplate, validateTemplateName) g.DELETE("/templates/:templateName", deleteTemplate, validateTemplateName)
g.GET("/templates/:templateName/:filePath", fetchTemplateFile, validateTemplateName, validateTemplateFilePath) g.GET("/templates/:templateName/:filePath", fetchTemplateFile, validateTemplateName, validateTemplateFilePath)
g.POST("/templates/:templateName/:filePath", updateTemplateFile, validateTemplateName, validateTemplateFilePath) g.POST("/templates/:templateName/:filePath", updateTemplateFile, validateTemplateName, validateTemplateFilePath)

View File

@@ -41,6 +41,7 @@ type buildTemplateOptions struct {
} }
var errTemplateNotFound = errors.New("template not found") var errTemplateNotFound = errors.New("template not found")
var errTemplateExists = errors.New("template already exists")
var errBaseTemplateNotFound = errors.New("base template not found") var errBaseTemplateNotFound = errors.New("base template not found")
var errTemplateFileNotFound = errors.New("template file not found") var errTemplateFileNotFound = errors.New("template file not found")

View File

@@ -1,6 +1,6 @@
import { promiseOrThrow } from "./lib/errors"; import { promiseOrThrow } from "./lib/errors";
interface ApiErrorResponse { interface ApiErrorDetails {
code: string; code: string;
error: string; error: string;
} }
@@ -8,12 +8,13 @@ interface ApiErrorResponse {
const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE"; const API_ERROR_BAD_TEMPLATE = "BAD_TEMPLATE";
const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS"; const API_ERROR_WORKSPACE_EXISTS = "WORKSPACE_EXISTS";
enum ApiError { type ApiError =
NotFound = "NOT_FOUND", | { type: "NOT_FOUND" }
BadRequest = "BAD_REQUEST", | { type: "NETWORK" }
Internal = "INTERNAL", | { type: "BAD_REQUEST" }
Network = "NETWORK", | { type: "CONFLICT" }
} | { type: "INTERNAL" }
| { type: "BAD_REQUEST"; details: ApiErrorDetails };
async function fetchApi( async function fetchApi(
url: URL | RequestInfo, url: URL | RequestInfo,
@@ -21,22 +22,27 @@ async function fetchApi(
): Promise<Response> { ): Promise<Response> {
const res = await promiseOrThrow( const res = await promiseOrThrow(
fetch(`${import.meta.env.VITE_API_URL}/api${url}`, init), fetch(`${import.meta.env.VITE_API_URL}/api${url}`, init),
() => ApiError.Network, () => ({ type: "NETWORK" }),
); );
if (res.status !== 200) { if (res.status !== 200) {
switch (res.status) { switch (res.status) {
case 400: case 400:
throw await res.json(); throw {
type: "BAD_REQUEST",
details: await res.json(),
};
case 404: case 404:
throw ApiError.NotFound; throw { type: "NOT_FOUND" };
case 409:
throw { type: "CONFLICT" };
default: default:
throw ApiError.Internal; throw { type: "INTERNAL" };
} }
} }
return res; return res;
} }
function isApiErrorResponse(error: unknown): error is ApiErrorResponse { function isApiErrorResponse(error: unknown): error is ApiErrorDetails {
return ( return (
error !== null && error !== null &&
error !== undefined && error !== undefined &&
@@ -49,8 +55,7 @@ function isApiErrorResponse(error: unknown): error is ApiErrorResponse {
export { export {
API_ERROR_BAD_TEMPLATE, API_ERROR_BAD_TEMPLATE,
API_ERROR_WORKSPACE_EXISTS, API_ERROR_WORKSPACE_EXISTS,
ApiError,
fetchApi, fetchApi,
isApiErrorResponse, isApiErrorResponse,
}; };
export type { ApiErrorResponse }; export type { ApiError, ApiErrorDetails };

View File

@@ -6,7 +6,8 @@ import type {
TemplateImage, TemplateImage,
BaseTemplate, BaseTemplate,
} from "./types"; } from "./types";
import { fetchApi } from "@/api"; import { ApiError, fetchApi } from "@/api";
import { promiseOrThrow } from "@/lib/errors";
function useTemplates() { function useTemplates() {
return useSWR( return useSWR(
@@ -17,7 +18,7 @@ function useTemplates() {
} }
function useTemplate(name: string) { function useTemplate(name: string) {
return useSWR( return useSWR<Template, ApiError>(
["/templates", name], ["/templates", name],
async (): Promise<Template> => async (): Promise<Template> =>
fetchApi(`/templates/${name}`).then((res) => res.json()), fetchApi(`/templates/${name}`).then((res) => res.json()),
@@ -45,7 +46,7 @@ function useDeleteTemplate() {
function useCreateTemplate() { function useCreateTemplate() {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<unknown | null>(null); const [error, setError] = useState<ApiError | null>(null);
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const createTemplate = useCallback( const createTemplate = useCallback(
@@ -60,14 +61,17 @@ function useCreateTemplate() {
}): Promise<Template | null> => { }): Promise<Template | null> => {
try { try {
const res = await fetchApi(`/templates/${name}`, { const res = await fetchApi(`/templates/${name}`, {
method: "POST", method: "PUT",
body: JSON.stringify({ description, baseTemplate }), body: JSON.stringify({ description, baseTemplate }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
const template: Template = await res.json(); const template = await promiseOrThrow<Template, ApiError>(
res.json(),
() => ({ type: "INTERNAL" }),
);
mutate(["/templates", name], template, { mutate(["/templates", name], template, {
populateCache: (newTemplate) => newTemplate, populateCache: (newTemplate) => newTemplate,
revalidate: false, revalidate: false,
@@ -75,7 +79,7 @@ function useCreateTemplate() {
return template; return template;
} catch (err: unknown) { } catch (err: unknown) {
setError(err); setError(err as ApiError);
return null; return null;
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -88,10 +92,12 @@ function useCreateTemplate() {
} }
function useTemplateFile(templateName: string, filePath: string) { function useTemplateFile(templateName: string, filePath: string) {
return useSWR(filePath ? ["/templates", templateName, filePath] : null, () => return useSWR<string, ApiError>(
fetchApi(`/templates/${templateName}/${filePath}`).then((res) => filePath ? ["/templates", templateName, filePath] : null,
res.text(), () =>
), fetchApi(`/templates/${templateName}/${filePath}`).then((res) =>
res.text(),
),
); );
} }

View File

@@ -30,6 +30,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { BaseTemplate } from "./types"; import type { BaseTemplate } from "./types";
import { useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
const NewTemplateForm = object({ const NewTemplateForm = object({
baseTemplate: nonempty(string()), baseTemplate: nonempty(string()),
@@ -75,7 +77,8 @@ function TemplateFormContainer() {
function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) { function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) {
const router = useRouter(); const router = useRouter();
const { createTemplate, isCreatingTemplate } = useCreateTemplate(); const { createTemplate, isCreatingTemplate, error } = useCreateTemplate();
const { toast } = useToast();
const form = useForm({ const form = useForm({
resolver: superstructResolver(NewTemplateForm), resolver: superstructResolver(NewTemplateForm),
disabled: isCreatingTemplate, disabled: isCreatingTemplate,
@@ -86,6 +89,36 @@ function TemplateForm({ baseTemplates }: { baseTemplates: BaseTemplate[] }) {
}, },
}); });
useEffect(() => {
if (!error) return;
switch (error.type) {
case "CONFLICT":
toast({
variant: "destructive",
title: "Template already exists",
description: "Please use another name for the template",
});
break;
case "NETWORK":
toast({
variant: "destructive",
title: "Failed to create the template",
description: "Network error",
});
break;
default:
toast({
variant: "destructive",
title: "Failed to create the template",
description: "Unknown error",
});
break;
}
}, [error, toast]);
async function onSubmit(values: Infer<typeof NewTemplateForm>) { async function onSubmit(values: Infer<typeof NewTemplateForm>) {
const createdTemplate = await createTemplate({ const createdTemplate = await createTemplate({
name: values.templateName, name: values.templateName,

View File

@@ -1,4 +1,4 @@
import { API_ERROR_BAD_TEMPLATE, ApiError } from "@/api"; import { API_ERROR_BAD_TEMPLATE } from "@/api";
import { CodeMirrorEditor } from "@/components/codemirror-editor"; import { CodeMirrorEditor } from "@/components/codemirror-editor";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { import {
@@ -12,6 +12,8 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
} from "@/components/ui/sidebar.tsx"; } from "@/components/ui/sidebar.tsx";
import { Toaster } from "@/components/ui/toaster";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Link, useRouter } from "@tanstack/react-router"; import { Link, useRouter } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { ArrowLeft, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
@@ -25,10 +27,8 @@ import {
createTemplateEditorStore, createTemplateEditorStore,
useTemplateEditorStore, useTemplateEditorStore,
} from "./template-editor-store"; } from "./template-editor-store";
import type { Template } from "./types";
import { useToast } from "@/hooks/use-toast";
import { Toaster } from "@/components/ui/toaster";
import { TemplateEditorTopBar } from "./template-editor-top-bar"; import { TemplateEditorTopBar } from "./template-editor-top-bar";
import type { Template } from "./types";
function TemplateEditor() { function TemplateEditor() {
const { templateName, _splat } = templateEditorRoute.useParams(); const { templateName, _splat } = templateEditorRoute.useParams();
@@ -43,13 +43,13 @@ function TemplateEditor() {
} }
if (error || !template) { if (error || !template) {
if (error === ApiError.NotFound) { if (error?.type === "NOT_FOUND") {
return <TemplateNotFound />; return <TemplateNotFound />;
} }
let message = ""; let message = "";
switch (error) { switch (error?.type) {
case ApiError.Network: case "NETWORK":
message = "Having trouble contacting the server."; message = "Having trouble contacting the server.";
break; break;
default: default:
@@ -178,11 +178,11 @@ function Editor() {
if (error || fileContent === undefined) { if (error || fileContent === undefined) {
let message = ""; let message = "";
switch (error) { switch (error?.type) {
case ApiError.NotFound: case "NOT_FOUND":
message = "This file does not exist in the template."; message = "This file does not exist in the template.";
break; break;
case ApiError.Network: case "NETWORK":
message = "Having trouble contacting the server."; message = "Having trouble contacting the server.";
break; break;
default: default: