From cad2beb902fe8610911fd5282390b82d4877be12 Mon Sep 17 00:00:00 2001 From: rslangl Date: Thu, 30 Apr 2026 19:23:06 +0200 Subject: [PATCH 1/5] feat: add repository endpoint --- api/api.yaml | 72 ++++++++++++++++++++++++++++++++- internal/api/api.gen.go | 88 +++++++++++++++++++++++++++++++++++++++++ internal/api/impl.go | 19 +++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/api/api.yaml b/api/api.yaml index e754a77..3612ba1 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -4,6 +4,68 @@ info: title: "Artifact Store API specification" version: "0.1.0" paths: + /v1/repositories: + get: + summary: "Get all available repositories" + operationId: "getRepositories" + responses: + '200': + description: List of repositories + content: + application/json: + schema: + type: array + $ref: '#/components/schemas/Repository' + post: + summary: "Create repository" + operationId: "addRepository" + operation: addRepository + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + name: + type: string + artifact: + type: string + description: "The artifact kind the repository will host" + enum: + - helm + required: + - name + - artifact + responses: + '201': + description: "Repository created" + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + '400': + description: "Bad request" + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: "Conflict — repository already exists" + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: "Internal server error" + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /v1/helm: get: summary: "Get all available Helm charts" @@ -103,7 +165,6 @@ paths: summary: "Add Helm chart" operationId: "addChart" requestBody: - required: true content: multipart/form-data: schema: @@ -158,6 +219,15 @@ components: type: integer message: type: string + Repository: + type: object + properties: + name: + type: string + artifact: + type: string + enum: + - chart Chart: type: object properties: diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 538a50e..6bbd698 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -13,6 +13,36 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Defines values for RepositoryArtifact. +const ( + RepositoryArtifactChart RepositoryArtifact = "chart" +) + +// Valid indicates whether the value is a known member of the RepositoryArtifact enum. +func (e RepositoryArtifact) Valid() bool { + switch e { + case RepositoryArtifactChart: + return true + default: + return false + } +} + +// Defines values for AddRepositoryMultipartBodyArtifact. +const ( + Helm AddRepositoryMultipartBodyArtifact = "helm" +) + +// Valid indicates whether the value is a known member of the AddRepositoryMultipartBodyArtifact enum. +func (e AddRepositoryMultipartBodyArtifact) Valid() bool { + switch e { + case Helm: + return true + default: + return false + } +} + // Chart defines model for Chart. type Chart struct { Id *int64 `json:"id,omitempty"` @@ -25,6 +55,15 @@ type Error struct { Message *string `json:"message,omitempty"` } +// Repository defines model for Repository. +type Repository struct { + Artifact *RepositoryArtifact `json:"artifact,omitempty"` + Name *string `json:"name,omitempty"` +} + +// RepositoryArtifact defines model for Repository.Artifact. +type RepositoryArtifact string + // AddChartMultipartBody defines parameters for AddChart. type AddChartMultipartBody struct { // Chart The packaged chart file (.tgz) @@ -32,9 +71,22 @@ type AddChartMultipartBody struct { Name *string `json:"name,omitempty"` } +// AddRepositoryMultipartBody defines parameters for AddRepository. +type AddRepositoryMultipartBody struct { + // Artifact The artifact kind the repository will host + Artifact AddRepositoryMultipartBodyArtifact `json:"artifact"` + Name string `json:"name"` +} + +// AddRepositoryMultipartBodyArtifact defines parameters for AddRepository. +type AddRepositoryMultipartBodyArtifact string + // AddChartMultipartRequestBody defines body for AddChart for multipart/form-data ContentType. type AddChartMultipartRequestBody AddChartMultipartBody +// AddRepositoryMultipartRequestBody defines body for AddRepository for multipart/form-data ContentType. +type AddRepositoryMultipartRequestBody AddRepositoryMultipartBody + // ServerInterface represents all server handlers. type ServerInterface interface { // Get all available Helm charts @@ -49,6 +101,12 @@ type ServerInterface interface { // Download version of Helm chart // (GET /v1/helm/{name}/{version}) GetChart(w http.ResponseWriter, r *http.Request, name string, version string) + // Get all available repositories + // (GET /v1/repositories) + GetRepositories(w http.ResponseWriter, r *http.Request) + // Create repository + // (POST /v1/repositories) + AddRepository(w http.ResponseWriter, r *http.Request) } // ServerInterfaceWrapper converts contexts to parameters. @@ -147,6 +205,34 @@ func (siw *ServerInterfaceWrapper) GetChart(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// GetRepositories operation middleware +func (siw *ServerInterfaceWrapper) GetRepositories(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetRepositories(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AddRepository operation middleware +func (siw *ServerInterfaceWrapper) AddRepository(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AddRepository(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + type UnescapedCookieParamError struct { ParamName string Err error @@ -271,6 +357,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("POST "+options.BaseURL+"/v1/helm/", wrapper.AddChart) m.HandleFunc("GET "+options.BaseURL+"/v1/helm/{name}/versions", wrapper.GetChartVersions) m.HandleFunc("GET "+options.BaseURL+"/v1/helm/{name}/{version}", wrapper.GetChart) + m.HandleFunc("GET "+options.BaseURL+"/v1/repositories", wrapper.GetRepositories) + m.HandleFunc("POST "+options.BaseURL+"/v1/repositories", wrapper.AddRepository) return m } diff --git a/internal/api/impl.go b/internal/api/impl.go index 29aca1b..fd07b96 100644 --- a/internal/api/impl.go +++ b/internal/api/impl.go @@ -24,6 +24,25 @@ func NewServer(storageHandler storage.Storage) Server { } } +func (s Server) GetRepositories(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Error{ + Code: new(int(http.StatusOK)), + Message: new(string("")), + }) + return +} + +func (s Server) AddRepository(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Error{ + Code: new(int(http.StatusCreated)), + Message: new(string("Repository created")), + }) + return +} + func (s Server) GetCharts(w http.ResponseWriter, r *http.Request) { data, err := s.storageHandler.Read("", "") if err != nil { From f38162ac77146628edfd6051ae0945d6d27fe5a8 Mon Sep 17 00:00:00 2001 From: rslangl Date: Thu, 30 Apr 2026 19:38:40 +0200 Subject: [PATCH 2/5] chore: split implementations into multiple files for maintainability --- internal/api/{impl.go => pkg_helm.go} | 33 +-------------------------- internal/api/repositories.go | 32 ++++++++++++++++++++++++++ internal/api/server.go | 26 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 32 deletions(-) rename internal/api/{impl.go => pkg_helm.go} (84%) create mode 100644 internal/api/repositories.go create mode 100644 internal/api/server.go diff --git a/internal/api/impl.go b/internal/api/pkg_helm.go similarity index 84% rename from internal/api/impl.go rename to internal/api/pkg_helm.go index fd07b96..a9f1366 100644 --- a/internal/api/impl.go +++ b/internal/api/pkg_helm.go @@ -8,41 +8,10 @@ import ( "net/http" openapi_types "github.com/oapi-codegen/runtime/types" - "artifacts/internal/storage" + //"artifacts/internal/storage" "artifacts/internal/storage/storage_error" ) -var chartMIMEType = []string{"application/gzip"} - -type Server struct{ - storageHandler storage.Storage -} - -func NewServer(storageHandler storage.Storage) Server { - return Server{ - storageHandler: storageHandler, - } -} - -func (s Server) GetRepositories(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(Error{ - Code: new(int(http.StatusOK)), - Message: new(string("")), - }) - return -} - -func (s Server) AddRepository(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(Error{ - Code: new(int(http.StatusCreated)), - Message: new(string("Repository created")), - }) - return -} - func (s Server) GetCharts(w http.ResponseWriter, r *http.Request) { data, err := s.storageHandler.Read("", "") if err != nil { diff --git a/internal/api/repositories.go b/internal/api/repositories.go new file mode 100644 index 0000000..23fa4a0 --- /dev/null +++ b/internal/api/repositories.go @@ -0,0 +1,32 @@ +package api + +import ( + "encoding/json" + // "fmt" + // "io" + // "slices" + "net/http" + //openapi_types "github.com/oapi-codegen/runtime/types" + + // "artifacts/internal/storage" + // "artifacts/internal/storage/storage_error" +) + +func (s Server) GetRepositories(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(Error{ + Code: new(int(http.StatusOK)), + Message: new(string("")), + }) + return +} + +func (s Server) AddRepository(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Error{ + Code: new(int(http.StatusCreated)), + Message: new(string("Repository created")), + }) + return +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..db9427f --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,26 @@ +package api + +import ( + // "encoding/json" + // "fmt" + // "io" + // "slices" + // "net/http" + // openapi_types "github.com/oapi-codegen/runtime/types" + // + "artifacts/internal/storage" + //"artifacts/internal/storage/storage_error" +) + +var chartMIMEType = []string{"application/gzip"} + +type Server struct{ + storageHandler storage.Storage +} + +func NewServer(storageHandler storage.Storage) Server { + return Server{ + storageHandler: storageHandler, + } +} + From c49f6664bae5a2a68ae9b8a2bc667fbcf22d0424 Mon Sep 17 00:00:00 2001 From: rslangl Date: Thu, 30 Apr 2026 21:33:50 +0200 Subject: [PATCH 3/5] fix: reflect spec changes in impl --- api/api.yaml | 16 ++++++++++++++-- internal/api/api.gen.go | 27 +++++++++++++++++++-------- internal/api/pkg_helm.go | 18 ++++++++++++++---- internal/storage/backend/fs.go | 8 ++++---- internal/storage/storage.go | 4 ++-- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/api/api.yaml b/api/api.yaml index 3612ba1..b4ec1c4 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -28,6 +28,8 @@ paths: properties: name: type: string + repository: + type: string artifact: type: string description: "The artifact kind the repository will host" @@ -35,6 +37,7 @@ paths: - helm required: - name + - repository - artifact responses: '201': @@ -123,18 +126,25 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /v1/helm/{name}/{version}: + /v1/helm/{repository}/{name}/{version}: get: summary: "Download version of Helm chart" operationId: "getChart" parameters: + - name: repository + in: path + required: false + schema: + type: string + default: default + description: "Chart repository" - name: name in: path required: true schema: type: string description: "Helm chart name" - - name: version + - name: version # TODO: make optional, default to 'latest' in: path required: true schema: @@ -172,6 +182,8 @@ paths: properties: name: type: string + repository: + type: string chart: type: string format: binary diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 6bbd698..cf2f7ae 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -67,15 +67,17 @@ type RepositoryArtifact string // AddChartMultipartBody defines parameters for AddChart. type AddChartMultipartBody struct { // Chart The packaged chart file (.tgz) - Chart *openapi_types.File `json:"chart,omitempty"` - Name *string `json:"name,omitempty"` + Chart *openapi_types.File `json:"chart,omitempty"` + Name *string `json:"name,omitempty"` + Repository *string `json:"repository,omitempty"` } // AddRepositoryMultipartBody defines parameters for AddRepository. type AddRepositoryMultipartBody struct { // Artifact The artifact kind the repository will host - Artifact AddRepositoryMultipartBodyArtifact `json:"artifact"` - Name string `json:"name"` + Artifact AddRepositoryMultipartBodyArtifact `json:"artifact"` + Name string `json:"name"` + Repository string `json:"repository"` } // AddRepositoryMultipartBodyArtifact defines parameters for AddRepository. @@ -99,8 +101,8 @@ type ServerInterface interface { // (GET /v1/helm/{name}/versions) GetChartVersions(w http.ResponseWriter, r *http.Request, name string) // Download version of Helm chart - // (GET /v1/helm/{name}/{version}) - GetChart(w http.ResponseWriter, r *http.Request, name string, version string) + // (GET /v1/helm/{repository}/{name}/{version}) + GetChart(w http.ResponseWriter, r *http.Request, repository string, name string, version string) // Get all available repositories // (GET /v1/repositories) GetRepositories(w http.ResponseWriter, r *http.Request) @@ -176,6 +178,15 @@ func (siw *ServerInterfaceWrapper) GetChart(w http.ResponseWriter, r *http.Reque var err error + // ------------- Path parameter "repository" ------------- + var repository string + + err = runtime.BindStyledParameterWithOptions("simple", "repository", r.PathValue("repository"), &repository, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "repository", Err: err}) + return + } + // ------------- Path parameter "name" ------------- var name string @@ -195,7 +206,7 @@ func (siw *ServerInterfaceWrapper) GetChart(w http.ResponseWriter, r *http.Reque } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetChart(w, r, name, version) + siw.Handler.GetChart(w, r, repository, name, version) })) for _, middleware := range siw.HandlerMiddlewares { @@ -356,7 +367,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("GET "+options.BaseURL+"/v1/helm", wrapper.GetCharts) m.HandleFunc("POST "+options.BaseURL+"/v1/helm/", wrapper.AddChart) m.HandleFunc("GET "+options.BaseURL+"/v1/helm/{name}/versions", wrapper.GetChartVersions) - m.HandleFunc("GET "+options.BaseURL+"/v1/helm/{name}/{version}", wrapper.GetChart) + m.HandleFunc("GET "+options.BaseURL+"/v1/helm/{repository}/{name}/{version}", wrapper.GetChart) m.HandleFunc("GET "+options.BaseURL+"/v1/repositories", wrapper.GetRepositories) m.HandleFunc("POST "+options.BaseURL+"/v1/repositories", wrapper.AddRepository) diff --git a/internal/api/pkg_helm.go b/internal/api/pkg_helm.go index a9f1366..a01abf7 100644 --- a/internal/api/pkg_helm.go +++ b/internal/api/pkg_helm.go @@ -13,7 +13,7 @@ import ( ) func (s Server) GetCharts(w http.ResponseWriter, r *http.Request) { - data, err := s.storageHandler.Read("", "") + data, err := s.storageHandler.Read("", "", "") if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -22,10 +22,10 @@ func (s Server) GetCharts(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(data) } -func (s Server) GetChart(w http.ResponseWriter, r *http.Request, resource string, version string) { +func (s Server) GetChart(w http.ResponseWriter, r *http.Request, repository string, resource string, version string) { w.Header().Set("Content-Type", "application/json") - data, err := s.storageHandler.Read(resource, version) + data, err := s.storageHandler.Read(repository, resource, version) if err != nil { if err == storage_error.NotFound { @@ -77,6 +77,16 @@ func (s Server) AddChart(w http.ResponseWriter, r *http.Request) { } }() + repo := r.FormValue("repository") + if repo == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(Error{ + Code: new(int(http.StatusBadRequest)), + Message: new(string("Missing 'repository' parameter")), + }) + return + } + // Extract chart name from form data name := r.FormValue("name") if name == "" { @@ -132,7 +142,7 @@ func (s Server) AddChart(w http.ResponseWriter, r *http.Request) { }) } - if err := s.storageHandler.Write(name, bytes); err != nil { // TODO: return created version + if err := s.storageHandler.Write(repo, name, bytes); err != nil { // TODO: return created version w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(Error{ Code: new(int(http.StatusInternalServerError)), diff --git a/internal/storage/backend/fs.go b/internal/storage/backend/fs.go index 7178433..acc998d 100644 --- a/internal/storage/backend/fs.go +++ b/internal/storage/backend/fs.go @@ -26,8 +26,8 @@ func NewFSBackend(path string) (*FileSystem, error) { } // Implementation of the `Writer` interface -func (f *FileSystem) Write(name string, bytes []byte) error { // TODO: define type `artifact` or similar instead - path := filepath.Join(f.root, name) +func (f *FileSystem) Write(repository string, name string, bytes []byte) error { // TODO: define type `artifact` or similar instead + path := filepath.Join(f.root, repository, name) // TODO: to be implemented when we decide to add repositories // if err := os.MkdirAll(filepath.Dir(), 0o744); err != nil { @@ -49,8 +49,8 @@ func (f *FileSystem) Write(name string, bytes []byte) error { // TODO: define ty } // Implementation of the `Reader` interface -func (f *FileSystem) Read(resource string, version string) ([]byte, error) { - dir, err := f.Path.Open(filepath.Join(resource, version)) +func (f *FileSystem) Read(repository string, resource string, version string) ([]byte, error) { + dir, err := f.Path.Open(filepath.Join(repository, resource, version)) if err != nil { return nil, storage_error.IOError } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1dfb5a1..04bcd5b 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -8,8 +8,8 @@ import ( ) type Storage interface { - Read(location string, version string) ([]byte, error) - Write(name string, bytes []byte) error + Read(repository string, resource string, version string) ([]byte, error) + Write(repository string, name string, bytes []byte) error } func New(config config.StorageConfig) (Storage, error) { From d5ddd96eb0df9a3000c186e4ce82d8c722ec2098 Mon Sep 17 00:00:00 2001 From: rslangl Date: Thu, 30 Apr 2026 21:54:45 +0200 Subject: [PATCH 4/5] fix: ensure target path for writing includes repository --- internal/api/pkg_helm.go | 2 ++ internal/api/server.go | 2 -- internal/storage/backend/fs.go | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/api/pkg_helm.go b/internal/api/pkg_helm.go index a01abf7..40f3939 100644 --- a/internal/api/pkg_helm.go +++ b/internal/api/pkg_helm.go @@ -12,6 +12,8 @@ import ( "artifacts/internal/storage/storage_error" ) +var chartMIMEType = []string{"application/octet-stream"} + func (s Server) GetCharts(w http.ResponseWriter, r *http.Request) { data, err := s.storageHandler.Read("", "", "") if err != nil { diff --git a/internal/api/server.go b/internal/api/server.go index db9427f..fa8865f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -12,8 +12,6 @@ import ( //"artifacts/internal/storage/storage_error" ) -var chartMIMEType = []string{"application/gzip"} - type Server struct{ storageHandler storage.Storage } diff --git a/internal/storage/backend/fs.go b/internal/storage/backend/fs.go index acc998d..741efa0 100644 --- a/internal/storage/backend/fs.go +++ b/internal/storage/backend/fs.go @@ -29,10 +29,10 @@ func NewFSBackend(path string) (*FileSystem, error) { func (f *FileSystem) Write(repository string, name string, bytes []byte) error { // TODO: define type `artifact` or similar instead path := filepath.Join(f.root, repository, name) - // TODO: to be implemented when we decide to add repositories - // if err := os.MkdirAll(filepath.Dir(), 0o744); err != nil { - // return err - // } + if err := os.MkdirAll(filepath.Dir(path), 0o744); err != nil { + log.Fatal(err) + return err + } file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { From 33b046c88f446b2dbc789c1580931184265a0a39 Mon Sep 17 00:00:00 2001 From: rslangl Date: Thu, 30 Apr 2026 23:14:02 +0200 Subject: [PATCH 5/5] chore: add simple test as starting point for future endpoint unit testing --- internal/api/repositories_test.go | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 internal/api/repositories_test.go diff --git a/internal/api/repositories_test.go b/internal/api/repositories_test.go new file mode 100644 index 0000000..0420c82 --- /dev/null +++ b/internal/api/repositories_test.go @@ -0,0 +1,50 @@ +package api + +import ( + //"encoding/json" + "net/http" + "net/http/httptest" + //"strings" + "testing" +) + +func newTestHandler() http.Handler { + s := NewServer(nil) + mux := http.NewServeMux() + h := HandlerFromMux(s, mux) + return h +} + +func TestCreate(t *testing.T) { + handler := newTestHandler() + + tests := []struct{ + name string + method string + path string + wantCode int + wantBody string + }{ + { + "GET repositories found", + "GET", + "/v1/repositories", + http.StatusOK, + "{}", + }, + } + + for _, testInput := range tests { + t.Run(testInput.name, func(t *testing.T) { + + req := httptest.NewRequest(testInput.method, testInput.path, nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != testInput.wantCode { + t.Errorf("expected error code '%v', got '%v'", testInput.wantCode, rr.Code) + } + }) + } +}