From 40525eef74c00736e4b38811b24bd899661a5637 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 5 Feb 2026 23:45:50 +0100 Subject: [PATCH 1/3] feat: expose http services on a single port and merged specs, closes #145 --- cmd/lunogram/main.go | 19 +-- internal/claim/rbac/rbac.go | 3 +- internal/config/config.go | 23 ++- internal/http/auth/auth.go | 1 + .../v1/{public => client}/client.go | 8 +- .../v1/{public => client}/client_test.go | 38 +++-- .../v1/{public => client}/controller.go | 0 .../v1/{public => client}/oapi/middleware.go | 18 -- .../v1/{public => client}/oapi/oapi.go | 4 +- .../v1/{public => client}/oapi/resources.yml | 25 +-- .../{public => client}/oapi/resources_gen.go | 158 +++++++----------- .../v1/{public => client}/static/generate.go | 0 .../v1/{public => client}/static/input.css | 0 .../v1/{public => client}/static/styles.css | 0 .../v1/{public => client}/subscriptions.go | 2 +- .../templates/preferences.html | 0 .../templates/unsubscribe.html | 0 internal/http/controllers/v1/http.go | 134 +++++++++++++++ .../http/controllers/v1/management/http.go | 62 ------- .../v1/management/oapi/middleware.go | 24 --- .../controllers/v1/management/oapi/oapi.go | 4 +- internal/http/controllers/v1/public/http.go | 62 ------- internal/http/scalar/index.html | 14 +- 23 files changed, 265 insertions(+), 334 deletions(-) rename internal/http/controllers/v1/{public => client}/client.go (94%) rename internal/http/controllers/v1/{public => client}/client_test.go (94%) rename internal/http/controllers/v1/{public => client}/controller.go (100%) rename internal/http/controllers/v1/{public => client}/oapi/middleware.go (60%) rename internal/http/controllers/v1/{public => client}/oapi/oapi.go (95%) rename internal/http/controllers/v1/{public => client}/oapi/resources.yml (94%) rename internal/http/controllers/v1/{public => client}/oapi/resources_gen.go (81%) rename internal/http/controllers/v1/{public => client}/static/generate.go (100%) rename internal/http/controllers/v1/{public => client}/static/input.css (100%) rename internal/http/controllers/v1/{public => client}/static/styles.css (100%) rename internal/http/controllers/v1/{public => client}/subscriptions.go (99%) rename internal/http/controllers/v1/{public => client}/templates/preferences.html (100%) rename internal/http/controllers/v1/{public => client}/templates/unsubscribe.html (100%) create mode 100644 internal/http/controllers/v1/http.go delete mode 100644 internal/http/controllers/v1/management/http.go delete mode 100644 internal/http/controllers/v1/public/http.go diff --git a/cmd/lunogram/main.go b/cmd/lunogram/main.go index 17c0a5be..6e886536 100644 --- a/cmd/lunogram/main.go +++ b/cmd/lunogram/main.go @@ -13,8 +13,7 @@ import ( "github.com/lunogram/platform/internal/cluster/leader" "github.com/lunogram/platform/internal/cluster/scheduler" "github.com/lunogram/platform/internal/config" - managementv1 "github.com/lunogram/platform/internal/http/controllers/v1/management" - publicv1 "github.com/lunogram/platform/internal/http/controllers/v1/public" + v1 "github.com/lunogram/platform/internal/http/controllers/v1" "github.com/lunogram/platform/internal/providers" "github.com/lunogram/platform/internal/pubsub" "github.com/lunogram/platform/internal/pubsub/consumer" @@ -124,23 +123,15 @@ func run() error { return err } - logger.Info("starting http servers") + logger.Info("starting http server") - mgmt, err := managementv1.NewServer(ctx, logger, conf, managementDB, bucket, pub, registry) + server, err := v1.NewServer(ctx, logger, conf, managementDB, bucket, pub, registry) if err != nil { return err } - logger.Info("serving management http server") - go mgmt.Serve(ctx, conf.ManagementServiceAddress) - - public, err := publicv1.NewServer(ctx, logger, conf, managementDB, managementStore, usersStore, bucket, pub) - if err != nil { - return err - } - - logger.Info("serving public http server") - go public.Serve(ctx, conf.PublicServiceAddress) + logger.Info("serving http server") + go server.Serve(ctx, conf.HTTPAddress) logger.Info("service up and running!") ctx.AwaitKillSignal() diff --git a/internal/claim/rbac/rbac.go b/internal/claim/rbac/rbac.go index 9496bf80..79e1befc 100644 --- a/internal/claim/rbac/rbac.go +++ b/internal/claim/rbac/rbac.go @@ -10,9 +10,10 @@ type contextKey string const scopeKey contextKey = "admin" -// Scope represents an authenticated admin user in the context +// Scope represents an authenticated user in the context type Scope struct { OrganizationID uuid.UUID + ProjectID uuid.UUID } // WithScope stores the admin object in the context diff --git a/internal/config/config.go b/internal/config/config.go index bcfcba36..a8f104c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,18 +10,17 @@ import ( ) type Node struct { - NodeID string `env:"NODE_ID" envDefault:""` - ManagementServiceAddress string `env:"ADMIN_SERVICE_ADDRESS" envDefault:":8080"` - PublicServiceAddress string `env:"PUBLIC_SERVICE_ADDRESS" envDefault:":8081"` - DatabaseMigrate bool `env:"DATABASE_MIGRATE" envDefault:"true"` - Redis Redis `envPrefix:"REDIS_"` - Cluster Cluster `envPrefix:"CLUSTER_"` - Auth Auth `envPrefix:"AUTH_"` - Nats Nats `envPrefix:"NATS_"` - WASM WASM `envPrefix:"WASM_"` - HTTP http.Config - Store store.Config - Storage storage.Config + NodeID string `env:"NODE_ID" envDefault:""` + HTTPAddress string `env:"HTTP_ADDRESS" envDefault:":8080"` + DatabaseMigrate bool `env:"DATABASE_MIGRATE" envDefault:"true"` + Redis Redis `envPrefix:"REDIS_"` + Cluster Cluster `envPrefix:"CLUSTER_"` + Auth Auth `envPrefix:"AUTH_"` + Nats Nats `envPrefix:"NATS_"` + WASM WASM `envPrefix:"WASM_"` + HTTP http.Config + Store store.Config + Storage storage.Config } type Auth struct { diff --git a/internal/http/auth/auth.go b/internal/http/auth/auth.go index 47ff79c4..1f762a9e 100644 --- a/internal/http/auth/auth.go +++ b/internal/http/auth/auth.go @@ -115,6 +115,7 @@ func WithKey(mgmt *management.State) Handler { ctx = rbac.WithScope(ctx, &rbac.Scope{ OrganizationID: key.OrganizationID, + ProjectID: key.ProjectID, }) return ctx, nil diff --git a/internal/http/controllers/v1/public/client.go b/internal/http/controllers/v1/client/client.go similarity index 94% rename from internal/http/controllers/v1/public/client.go rename to internal/http/controllers/v1/client/client.go index 7daaa12c..91ecd5db 100644 --- a/internal/http/controllers/v1/public/client.go +++ b/internal/http/controllers/v1/client/client.go @@ -6,7 +6,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/lunogram/platform/internal/claim/rbac" - "github.com/lunogram/platform/internal/http/controllers/v1/public/oapi" + "github.com/lunogram/platform/internal/http/controllers/v1/client/oapi" "github.com/lunogram/platform/internal/http/json" "github.com/lunogram/platform/internal/http/problem" "github.com/lunogram/platform/internal/pubsub" @@ -31,7 +31,7 @@ type ClientController struct { pubsub pubsub.Publisher } -func (srv *ClientController) PostEvents(w http.ResponseWriter, r *http.Request, projectID uuid.UUID) { +func (srv *ClientController) PostEvents(w http.ResponseWriter, r *http.Request) { ctx := r.Context() scope := rbac.FromContext(ctx) @@ -41,6 +41,7 @@ func (srv *ClientController) PostEvents(w http.ResponseWriter, r *http.Request, return } + projectID := scope.ProjectID if projectID == uuid.Nil { srv.logger.Error("project_id is required") oapi.WriteProblem(w, problem.ErrUnauthorized()) @@ -78,7 +79,7 @@ func (srv *ClientController) PostEvents(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusAccepted) } -func (srv *ClientController) IdentifyUser(w http.ResponseWriter, r *http.Request, projectID uuid.UUID) { +func (srv *ClientController) IdentifyUserClient(w http.ResponseWriter, r *http.Request) { ctx := r.Context() scope := rbac.FromContext(ctx) @@ -88,6 +89,7 @@ func (srv *ClientController) IdentifyUser(w http.ResponseWriter, r *http.Request return } + projectID := scope.ProjectID if projectID == uuid.Nil { srv.logger.Error("project_id is required") oapi.WriteProblem(w, problem.ErrUnauthorized()) diff --git a/internal/http/controllers/v1/public/client_test.go b/internal/http/controllers/v1/client/client_test.go similarity index 94% rename from internal/http/controllers/v1/public/client_test.go rename to internal/http/controllers/v1/client/client_test.go index 0c30c6a4..26b3c2b7 100644 --- a/internal/http/controllers/v1/public/client_test.go +++ b/internal/http/controllers/v1/client/client_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/cloudproud/graceful" - "github.com/google/uuid" "github.com/lunogram/platform/internal/claim/rbac" "github.com/lunogram/platform/internal/config" "github.com/lunogram/platform/internal/container" @@ -153,10 +152,11 @@ func TestPostEvents(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.PostEvents(w, req, projectID) + controller.PostEvents(w, req) assert.Equal(t, tc.statusCode, w.Code) }) @@ -183,10 +183,11 @@ func TestPostEventsInvalidRequest(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.PostEvents(w, req, projectID) + controller.PostEvents(w, req) assert.Equal(t, 400, w.Code) } @@ -210,7 +211,7 @@ func TestPostEventsMissingRBACScope(t *testing.T) { req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - controller.PostEvents(w, req, uuid.Nil) + controller.PostEvents(w, req) assert.Equal(t, 401, w.Code) } @@ -237,10 +238,11 @@ func TestPostEventsMissingProjectID(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + // ProjectID is intentionally left as uuid.Nil })) w := httptest.NewRecorder() - controller.PostEvents(w, req, uuid.Nil) + controller.PostEvents(w, req) assert.Equal(t, 401, w.Code) } @@ -288,10 +290,11 @@ func TestPostEventsWithNestedData(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.PostEvents(w, req, projectID) + controller.PostEvents(w, req) assert.Equal(t, 202, w.Code) } @@ -321,10 +324,11 @@ func TestPostEventsEmptyArray(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.PostEvents(w, req, projectID) + controller.PostEvents(w, req) assert.Equal(t, 202, w.Code) } @@ -399,10 +403,11 @@ func TestClientIdentifyUser(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.IdentifyUser(w, req, projectID) + controller.IdentifyUserClient(w, req) assert.Equal(t, tc.statusCode, w.Code) if w.Code == 200 { @@ -465,10 +470,11 @@ func TestClientIdentifyUserInvalidRequest(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.IdentifyUser(w, req, projectID) + controller.IdentifyUserClient(w, req) assert.Equal(t, tc.statusCode, w.Code) }) @@ -490,7 +496,7 @@ func TestClientIdentifyUserMissingRBACScope(t *testing.T) { req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() - controller.IdentifyUser(w, req, uuid.Nil) + controller.IdentifyUserClient(w, req) assert.Equal(t, 401, w.Code) } @@ -513,10 +519,11 @@ func TestClientIdentifyUserMissingProjectID(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + // ProjectID is intentionally left as uuid.Nil })) w := httptest.NewRecorder() - controller.IdentifyUser(w, req, uuid.Nil) + controller.IdentifyUserClient(w, req) assert.Equal(t, 401, w.Code) } @@ -552,10 +559,11 @@ func TestClientIdentifyUserUpdateExisting(t *testing.T) { req1.Header.Set("Content-Type", "application/json") req1 = req1.WithContext(rbac.WithScope(req1.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w1 := httptest.NewRecorder() - controller.IdentifyUser(w1, req1, projectID) + controller.IdentifyUserClient(w1, req1) assert.Equal(t, 200, w1.Code) @@ -580,10 +588,11 @@ func TestClientIdentifyUserUpdateExisting(t *testing.T) { req2.Header.Set("Content-Type", "application/json") req2 = req2.WithContext(rbac.WithScope(req2.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w2 := httptest.NewRecorder() - controller.IdentifyUser(w2, req2, projectID) + controller.IdentifyUserClient(w2, req2) assert.Equal(t, 200, w2.Code) @@ -627,10 +636,11 @@ func TestClientIdentifyUserWithBothIdentifiers(t *testing.T) { req.Header.Set("Content-Type", "application/json") req = req.WithContext(rbac.WithScope(req.Context(), &rbac.Scope{ OrganizationID: orgID, + ProjectID: projectID, })) w := httptest.NewRecorder() - controller.IdentifyUser(w, req, projectID) + controller.IdentifyUserClient(w, req) assert.Equal(t, 200, w.Code) diff --git a/internal/http/controllers/v1/public/controller.go b/internal/http/controllers/v1/client/controller.go similarity index 100% rename from internal/http/controllers/v1/public/controller.go rename to internal/http/controllers/v1/client/controller.go diff --git a/internal/http/controllers/v1/public/oapi/middleware.go b/internal/http/controllers/v1/client/oapi/middleware.go similarity index 60% rename from internal/http/controllers/v1/public/oapi/middleware.go rename to internal/http/controllers/v1/client/oapi/middleware.go index fd554312..9c369b01 100644 --- a/internal/http/controllers/v1/public/oapi/middleware.go +++ b/internal/http/controllers/v1/client/oapi/middleware.go @@ -7,7 +7,6 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/lunogram/platform/internal/http/problem" - "github.com/lunogram/platform/internal/http/scalar" middleware "github.com/oapi-codegen/nethttp-middleware" ) @@ -21,20 +20,3 @@ func Validator(spec *openapi3.T, options openapi3filter.Options) func(next http. }, }) } - -func Scalar() func(next http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/openapi.yaml" { - scalar.HandleOAPI(oapi).ServeHTTP(w, req) - return - } - if req.URL.Path == "/" { - http.FileServer(http.FS(scalar.FS)).ServeHTTP(w, req) - return - } - - next.ServeHTTP(w, req) - }) - } -} diff --git a/internal/http/controllers/v1/public/oapi/oapi.go b/internal/http/controllers/v1/client/oapi/oapi.go similarity index 95% rename from internal/http/controllers/v1/public/oapi/oapi.go rename to internal/http/controllers/v1/client/oapi/oapi.go index 40bb3de3..f2223d8e 100644 --- a/internal/http/controllers/v1/public/oapi/oapi.go +++ b/internal/http/controllers/v1/client/oapi/oapi.go @@ -13,10 +13,10 @@ import ( //go:generate oapi-codegen -o ./resources_gen.go -generate types,client,chi-server -package oapi ./resources.yml //go:embed resources.yml -var oapi []byte +var OAPI []byte func Spec() (*openapi3.T, error) { - return openapi3.NewLoader().LoadFromData(oapi) + return openapi3.NewLoader().LoadFromData(OAPI) } // WriteProblem writes a JSON v1 problem message to the given response writer. diff --git a/internal/http/controllers/v1/public/oapi/resources.yml b/internal/http/controllers/v1/client/oapi/resources.yml similarity index 94% rename from internal/http/controllers/v1/public/oapi/resources.yml rename to internal/http/controllers/v1/client/oapi/resources.yml index f245fb84..8b55036d 100644 --- a/internal/http/controllers/v1/public/oapi/resources.yml +++ b/internal/http/controllers/v1/client/oapi/resources.yml @@ -5,23 +5,15 @@ info: version: 1.0.0 paths: - /api/client/projects/{projectID}/identify: + /api/client/identify: post: summary: Identify user - description: Used by client libraries to create or update a user profile - operationId: identifyUser + description: Used by client libraries to create or update a user profile. The project is determined from the API key. + operationId: identifyUserClient tags: - Client security: - HttpBearerAuth: [] - parameters: - - name: projectID - in: path - required: true - schema: - type: string - format: uuid - description: The project ID requestBody: required: true content: @@ -38,7 +30,7 @@ paths: default: $ref: '#/components/responses/Error' - /api/client/projects/{projectID}/events: + /api/client/events: post: summary: Post events description: | @@ -46,19 +38,12 @@ paths: Multiple events can be sent in a single request and are processed asynchronously. Each event is handled independently: if one event fails to be accepted or processed, other events in the same request may still be published successfully. Clients requiring deterministic retry or recovery behavior must submit events individually. + The project is determined from the API key. operationId: postEvents tags: - Client security: - HttpBearerAuth: [] - parameters: - - name: projectID - in: path - required: true - schema: - type: string - format: uuid - description: The project ID requestBody: required: true content: diff --git a/internal/http/controllers/v1/public/oapi/resources_gen.go b/internal/http/controllers/v1/client/oapi/resources_gen.go similarity index 81% rename from internal/http/controllers/v1/public/oapi/resources_gen.go rename to internal/http/controllers/v1/client/oapi/resources_gen.go index 3b34fa5a..1c7406f6 100644 --- a/internal/http/controllers/v1/public/oapi/resources_gen.go +++ b/internal/http/controllers/v1/client/oapi/resources_gen.go @@ -105,8 +105,8 @@ type EmailUnsubscribeParams struct { // PostEventsJSONRequestBody defines body for PostEvents for application/json ContentType. type PostEventsJSONRequestBody = PostEventsRequest -// IdentifyUserJSONRequestBody defines body for IdentifyUser for application/json ContentType. -type IdentifyUserJSONRequestBody = IdentifyRequest +// IdentifyUserClientJSONRequestBody defines body for IdentifyUserClient for application/json ContentType. +type IdentifyUserClientJSONRequestBody = IdentifyRequest // UpdatePreferencesFormdataRequestBody defines body for UpdatePreferences for application/x-www-form-urlencoded ContentType. type UpdatePreferencesFormdataRequestBody UpdatePreferencesFormdataBody @@ -185,14 +185,14 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { // PostEventsWithBody request with any body - PostEventsWithBody(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + PostEventsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - PostEvents(ctx context.Context, projectID openapi_types.UUID, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + PostEvents(ctx context.Context, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // IdentifyUserWithBody request with any body - IdentifyUserWithBody(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // IdentifyUserClientWithBody request with any body + IdentifyUserClientWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - IdentifyUser(ctx context.Context, projectID openapi_types.UUID, body IdentifyUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + IdentifyUserClient(ctx context.Context, body IdentifyUserClientJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // GetPreferencesPage request GetPreferencesPage(ctx context.Context, projectID openapi_types.UUID, userID openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -206,8 +206,8 @@ type ClientInterface interface { EmailUnsubscribe(ctx context.Context, params *EmailUnsubscribeParams, reqEditors ...RequestEditorFn) (*http.Response, error) } -func (c *Client) PostEventsWithBody(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostEventsRequestWithBody(c.Server, projectID, contentType, body) +func (c *Client) PostEventsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostEventsRequestWithBody(c.Server, contentType, body) if err != nil { return nil, err } @@ -218,8 +218,8 @@ func (c *Client) PostEventsWithBody(ctx context.Context, projectID openapi_types return c.Client.Do(req) } -func (c *Client) PostEvents(ctx context.Context, projectID openapi_types.UUID, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostEventsRequest(c.Server, projectID, body) +func (c *Client) PostEvents(ctx context.Context, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostEventsRequest(c.Server, body) if err != nil { return nil, err } @@ -230,8 +230,8 @@ func (c *Client) PostEvents(ctx context.Context, projectID openapi_types.UUID, b return c.Client.Do(req) } -func (c *Client) IdentifyUserWithBody(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewIdentifyUserRequestWithBody(c.Server, projectID, contentType, body) +func (c *Client) IdentifyUserClientWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewIdentifyUserClientRequestWithBody(c.Server, contentType, body) if err != nil { return nil, err } @@ -242,8 +242,8 @@ func (c *Client) IdentifyUserWithBody(ctx context.Context, projectID openapi_typ return c.Client.Do(req) } -func (c *Client) IdentifyUser(ctx context.Context, projectID openapi_types.UUID, body IdentifyUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewIdentifyUserRequest(c.Server, projectID, body) +func (c *Client) IdentifyUserClient(ctx context.Context, body IdentifyUserClientJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewIdentifyUserClientRequest(c.Server, body) if err != nil { return nil, err } @@ -303,33 +303,26 @@ func (c *Client) EmailUnsubscribe(ctx context.Context, params *EmailUnsubscribeP } // NewPostEventsRequest calls the generic PostEvents builder with application/json body -func NewPostEventsRequest(server string, projectID openapi_types.UUID, body PostEventsJSONRequestBody) (*http.Request, error) { +func NewPostEventsRequest(server string, body PostEventsJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewPostEventsRequestWithBody(server, projectID, "application/json", bodyReader) + return NewPostEventsRequestWithBody(server, "application/json", bodyReader) } // NewPostEventsRequestWithBody generates requests for PostEvents with any type of body -func NewPostEventsRequestWithBody(server string, projectID openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { +func NewPostEventsRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "projectID", runtime.ParamLocationPath, projectID) - if err != nil { - return nil, err - } - serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/api/client/projects/%s/events", pathParam0) + operationPath := fmt.Sprintf("/api/client/events") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -349,34 +342,27 @@ func NewPostEventsRequestWithBody(server string, projectID openapi_types.UUID, c return req, nil } -// NewIdentifyUserRequest calls the generic IdentifyUser builder with application/json body -func NewIdentifyUserRequest(server string, projectID openapi_types.UUID, body IdentifyUserJSONRequestBody) (*http.Request, error) { +// NewIdentifyUserClientRequest calls the generic IdentifyUserClient builder with application/json body +func NewIdentifyUserClientRequest(server string, body IdentifyUserClientJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewIdentifyUserRequestWithBody(server, projectID, "application/json", bodyReader) + return NewIdentifyUserClientRequestWithBody(server, "application/json", bodyReader) } -// NewIdentifyUserRequestWithBody generates requests for IdentifyUser with any type of body -func NewIdentifyUserRequestWithBody(server string, projectID openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { +// NewIdentifyUserClientRequestWithBody generates requests for IdentifyUserClient with any type of body +func NewIdentifyUserClientRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error - var pathParam0 string - - pathParam0, err = runtime.StyleParamWithLocation("simple", false, "projectID", runtime.ParamLocationPath, projectID) - if err != nil { - return nil, err - } - serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/api/client/projects/%s/identify", pathParam0) + operationPath := fmt.Sprintf("/api/client/identify") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -580,14 +566,14 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // PostEventsWithBodyWithResponse request with any body - PostEventsWithBodyWithResponse(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) + PostEventsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) - PostEventsWithResponse(ctx context.Context, projectID openapi_types.UUID, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) + PostEventsWithResponse(ctx context.Context, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) - // IdentifyUserWithBodyWithResponse request with any body - IdentifyUserWithBodyWithResponse(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IdentifyUserResponse, error) + // IdentifyUserClientWithBodyWithResponse request with any body + IdentifyUserClientWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IdentifyUserClientResponse, error) - IdentifyUserWithResponse(ctx context.Context, projectID openapi_types.UUID, body IdentifyUserJSONRequestBody, reqEditors ...RequestEditorFn) (*IdentifyUserResponse, error) + IdentifyUserClientWithResponse(ctx context.Context, body IdentifyUserClientJSONRequestBody, reqEditors ...RequestEditorFn) (*IdentifyUserClientResponse, error) // GetPreferencesPageWithResponse request GetPreferencesPageWithResponse(ctx context.Context, projectID openapi_types.UUID, userID openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetPreferencesPageResponse, error) @@ -623,7 +609,7 @@ func (r PostEventsResponse) StatusCode() int { return 0 } -type IdentifyUserResponse struct { +type IdentifyUserClientResponse struct { Body []byte HTTPResponse *http.Response JSON200 *User @@ -631,7 +617,7 @@ type IdentifyUserResponse struct { } // Status returns HTTPResponse.Status -func (r IdentifyUserResponse) Status() string { +func (r IdentifyUserClientResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -639,7 +625,7 @@ func (r IdentifyUserResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r IdentifyUserResponse) StatusCode() int { +func (r IdentifyUserClientResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -710,37 +696,37 @@ func (r EmailUnsubscribeResponse) StatusCode() int { } // PostEventsWithBodyWithResponse request with arbitrary body returning *PostEventsResponse -func (c *ClientWithResponses) PostEventsWithBodyWithResponse(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) { - rsp, err := c.PostEventsWithBody(ctx, projectID, contentType, body, reqEditors...) +func (c *ClientWithResponses) PostEventsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) { + rsp, err := c.PostEventsWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } return ParsePostEventsResponse(rsp) } -func (c *ClientWithResponses) PostEventsWithResponse(ctx context.Context, projectID openapi_types.UUID, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) { - rsp, err := c.PostEvents(ctx, projectID, body, reqEditors...) +func (c *ClientWithResponses) PostEventsWithResponse(ctx context.Context, body PostEventsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostEventsResponse, error) { + rsp, err := c.PostEvents(ctx, body, reqEditors...) if err != nil { return nil, err } return ParsePostEventsResponse(rsp) } -// IdentifyUserWithBodyWithResponse request with arbitrary body returning *IdentifyUserResponse -func (c *ClientWithResponses) IdentifyUserWithBodyWithResponse(ctx context.Context, projectID openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IdentifyUserResponse, error) { - rsp, err := c.IdentifyUserWithBody(ctx, projectID, contentType, body, reqEditors...) +// IdentifyUserClientWithBodyWithResponse request with arbitrary body returning *IdentifyUserClientResponse +func (c *ClientWithResponses) IdentifyUserClientWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*IdentifyUserClientResponse, error) { + rsp, err := c.IdentifyUserClientWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseIdentifyUserResponse(rsp) + return ParseIdentifyUserClientResponse(rsp) } -func (c *ClientWithResponses) IdentifyUserWithResponse(ctx context.Context, projectID openapi_types.UUID, body IdentifyUserJSONRequestBody, reqEditors ...RequestEditorFn) (*IdentifyUserResponse, error) { - rsp, err := c.IdentifyUser(ctx, projectID, body, reqEditors...) +func (c *ClientWithResponses) IdentifyUserClientWithResponse(ctx context.Context, body IdentifyUserClientJSONRequestBody, reqEditors ...RequestEditorFn) (*IdentifyUserClientResponse, error) { + rsp, err := c.IdentifyUserClient(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseIdentifyUserResponse(rsp) + return ParseIdentifyUserClientResponse(rsp) } // GetPreferencesPageWithResponse request returning *GetPreferencesPageResponse @@ -804,15 +790,15 @@ func ParsePostEventsResponse(rsp *http.Response) (*PostEventsResponse, error) { return response, nil } -// ParseIdentifyUserResponse parses an HTTP response from a IdentifyUserWithResponse call -func ParseIdentifyUserResponse(rsp *http.Response) (*IdentifyUserResponse, error) { +// ParseIdentifyUserClientResponse parses an HTTP response from a IdentifyUserClientWithResponse call +func ParseIdentifyUserClientResponse(rsp *http.Response) (*IdentifyUserClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &IdentifyUserResponse{ + response := &IdentifyUserClientResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -888,11 +874,11 @@ func ParseEmailUnsubscribeResponse(rsp *http.Response) (*EmailUnsubscribeRespons // ServerInterface represents all server handlers. type ServerInterface interface { // Post events - // (POST /api/client/projects/{projectID}/events) - PostEvents(w http.ResponseWriter, r *http.Request, projectID openapi_types.UUID) + // (POST /api/client/events) + PostEvents(w http.ResponseWriter, r *http.Request) // Identify user - // (POST /api/client/projects/{projectID}/identify) - IdentifyUser(w http.ResponseWriter, r *http.Request, projectID openapi_types.UUID) + // (POST /api/client/identify) + IdentifyUserClient(w http.ResponseWriter, r *http.Request) // Subscription preferences page // (GET /preferences/{projectID}/{userID}) GetPreferencesPage(w http.ResponseWriter, r *http.Request, projectID openapi_types.UUID, userID openapi_types.UUID) @@ -909,14 +895,14 @@ type ServerInterface interface { type Unimplemented struct{} // Post events -// (POST /api/client/projects/{projectID}/events) -func (_ Unimplemented) PostEvents(w http.ResponseWriter, r *http.Request, projectID openapi_types.UUID) { +// (POST /api/client/events) +func (_ Unimplemented) PostEvents(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } // Identify user -// (POST /api/client/projects/{projectID}/identify) -func (_ Unimplemented) IdentifyUser(w http.ResponseWriter, r *http.Request, projectID openapi_types.UUID) { +// (POST /api/client/identify) +func (_ Unimplemented) IdentifyUserClient(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } @@ -950,17 +936,6 @@ type MiddlewareFunc func(http.Handler) http.Handler // PostEvents operation middleware func (siw *ServerInterfaceWrapper) PostEvents(w http.ResponseWriter, r *http.Request) { - var err error - - // ------------- Path parameter "projectID" ------------- - var projectID openapi_types.UUID - - err = runtime.BindStyledParameterWithOptions("simple", "projectID", chi.URLParam(r, "projectID"), &projectID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectID", Err: err}) - return - } - ctx := r.Context() ctx = context.WithValue(ctx, HttpBearerAuthScopes, []string{}) @@ -968,7 +943,7 @@ func (siw *ServerInterfaceWrapper) PostEvents(w http.ResponseWriter, r *http.Req r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.PostEvents(w, r, projectID) + siw.Handler.PostEvents(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -978,19 +953,8 @@ func (siw *ServerInterfaceWrapper) PostEvents(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } -// IdentifyUser operation middleware -func (siw *ServerInterfaceWrapper) IdentifyUser(w http.ResponseWriter, r *http.Request) { - - var err error - - // ------------- Path parameter "projectID" ------------- - var projectID openapi_types.UUID - - err = runtime.BindStyledParameterWithOptions("simple", "projectID", chi.URLParam(r, "projectID"), &projectID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectID", Err: err}) - return - } +// IdentifyUserClient operation middleware +func (siw *ServerInterfaceWrapper) IdentifyUserClient(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -999,7 +963,7 @@ func (siw *ServerInterfaceWrapper) IdentifyUser(w http.ResponseWriter, r *http.R r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.IdentifyUser(w, r, projectID) + siw.Handler.IdentifyUserClient(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -1225,10 +1189,10 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl } r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/client/projects/{projectID}/events", wrapper.PostEvents) + r.Post(options.BaseURL+"/api/client/events", wrapper.PostEvents) }) r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/api/client/projects/{projectID}/identify", wrapper.IdentifyUser) + r.Post(options.BaseURL+"/api/client/identify", wrapper.IdentifyUserClient) }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/preferences/{projectID}/{userID}", wrapper.GetPreferencesPage) diff --git a/internal/http/controllers/v1/public/static/generate.go b/internal/http/controllers/v1/client/static/generate.go similarity index 100% rename from internal/http/controllers/v1/public/static/generate.go rename to internal/http/controllers/v1/client/static/generate.go diff --git a/internal/http/controllers/v1/public/static/input.css b/internal/http/controllers/v1/client/static/input.css similarity index 100% rename from internal/http/controllers/v1/public/static/input.css rename to internal/http/controllers/v1/client/static/input.css diff --git a/internal/http/controllers/v1/public/static/styles.css b/internal/http/controllers/v1/client/static/styles.css similarity index 100% rename from internal/http/controllers/v1/public/static/styles.css rename to internal/http/controllers/v1/client/static/styles.css diff --git a/internal/http/controllers/v1/public/subscriptions.go b/internal/http/controllers/v1/client/subscriptions.go similarity index 99% rename from internal/http/controllers/v1/public/subscriptions.go rename to internal/http/controllers/v1/client/subscriptions.go index 1bde7e6c..e2997124 100644 --- a/internal/http/controllers/v1/public/subscriptions.go +++ b/internal/http/controllers/v1/client/subscriptions.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" - "github.com/lunogram/platform/internal/http/controllers/v1/public/oapi" + "github.com/lunogram/platform/internal/http/controllers/v1/client/oapi" "github.com/lunogram/platform/internal/http/problem" "github.com/lunogram/platform/internal/store/management" "github.com/lunogram/platform/internal/store/users" diff --git a/internal/http/controllers/v1/public/templates/preferences.html b/internal/http/controllers/v1/client/templates/preferences.html similarity index 100% rename from internal/http/controllers/v1/public/templates/preferences.html rename to internal/http/controllers/v1/client/templates/preferences.html diff --git a/internal/http/controllers/v1/public/templates/unsubscribe.html b/internal/http/controllers/v1/client/templates/unsubscribe.html similarity index 100% rename from internal/http/controllers/v1/public/templates/unsubscribe.html rename to internal/http/controllers/v1/client/templates/unsubscribe.html diff --git a/internal/http/controllers/v1/http.go b/internal/http/controllers/v1/http.go new file mode 100644 index 00000000..bfa74727 --- /dev/null +++ b/internal/http/controllers/v1/http.go @@ -0,0 +1,134 @@ +package v1 + +import ( + "embed" + "fmt" + "io/fs" + nethttp "net/http" + + "github.com/cloudproud/graceful" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" + "github.com/lunogram/platform/internal/config" + "github.com/lunogram/platform/internal/http" + "github.com/lunogram/platform/internal/http/auth" + "github.com/lunogram/platform/internal/http/console" + clientv1 "github.com/lunogram/platform/internal/http/controllers/v1/client" + clientoapi "github.com/lunogram/platform/internal/http/controllers/v1/client/oapi" + managementv1 "github.com/lunogram/platform/internal/http/controllers/v1/management" + mgmtoapi "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" + "github.com/lunogram/platform/internal/http/scalar" + "github.com/lunogram/platform/internal/providers" + "github.com/lunogram/platform/internal/pubsub" + "github.com/lunogram/platform/internal/storage" + "github.com/lunogram/platform/internal/store/management" + "github.com/lunogram/platform/internal/store/users" + "go.uber.org/zap" +) + +//go:embed client/static +var staticFiles embed.FS + +// NewServer constructs a unified HTTP server combining both management and client +// API endpoints. Management endpoints use JWT+API Key auth, while client endpoints +// use API Key only authentication. +func NewServer(ctx graceful.Context, logger *zap.Logger, cfg config.Node, db *sqlx.DB, storage storage.Storage, pub pubsub.Publisher, registry *providers.Registry) (*http.Server, error) { + mgmtStores := management.NewState(db) + usersStore := users.NewState(db) + + // Load OpenAPI specs + mgmtSpec, err := mgmtoapi.Spec() + if err != nil { + return nil, fmt.Errorf("failed to load management OpenAPI spec: %w", err) + } + + clientSpec, err := clientoapi.Spec() + if err != nil { + return nil, fmt.Errorf("failed to load client OpenAPI spec: %w", err) + } + + // Create management controller + mgmtController, err := managementv1.NewController(logger, db, cfg, storage, pub, registry) + if err != nil { + return nil, fmt.Errorf("failed to create management controller: %w", err) + } + + // Create client controller + clientController, err := clientv1.NewController(logger, db, mgmtStores, usersStore, pub) + if err != nil { + return nil, fmt.Errorf("failed to create client controller: %w", err) + } + + router := chi.NewRouter() + router.Use(http.Logger(logger)) + + // Serve unified API documentation with both specs + router.Use(apiDocsMiddleware()) + + // Mount management routes with JWT+API Key auth + mgmtoapi.HandlerWithOptions(mgmtController, mgmtoapi.ChiServerOptions{ + BaseRouter: router, + Middlewares: []mgmtoapi.MiddlewareFunc{mgmtoapi.Validator(mgmtSpec, openapi3filter.Options{ + AuthenticationFunc: auth.Middleware( + auth.WithJWT(cfg.Auth, mgmtStores), + auth.WithKey(mgmtStores), + ), + })}, + }) + + // Mount client routes with API Key only auth + clientoapi.HandlerWithOptions(clientController, clientoapi.ChiServerOptions{ + BaseRouter: router, + Middlewares: []clientoapi.MiddlewareFunc{clientoapi.Validator(clientSpec, openapi3filter.Options{ + AuthenticationFunc: auth.Middleware( + auth.WithKey(mgmtStores), + ), + })}, + }) + + // Serve static files for CSS - use sub-filesystem to strip the "client/static" prefix + staticSubFS, err := fs.Sub(staticFiles, "client/static") + if err != nil { + return nil, fmt.Errorf("failed to create static sub-filesystem: %w", err) + } + router.Handle("/static/*", nethttp.StripPrefix("/static/", nethttp.FileServer(nethttp.FS(staticSubFS)))) + + // Serve console (admin UI) as fallback + consoleHandler, err := console.Handler() + if err != nil { + return nil, fmt.Errorf("failed to create console handler: %w", err) + } + router.Handle("/*", consoleHandler) + + return http.NewServer(logger, router, cfg.HTTP), nil +} + +// apiDocsMiddleware serves the unified API documentation with both management and client specs. +func apiDocsMiddleware() func(next nethttp.Handler) nethttp.Handler { + return func(next nethttp.Handler) nethttp.Handler { + return nethttp.HandlerFunc(func(w nethttp.ResponseWriter, req *nethttp.Request) { + switch req.URL.Path { + case "/api/openapi/management.yaml": + w.Header().Set("Content-Type", "application/x-yaml") + w.Write(mgmtoapi.OAPI) //nolint:errcheck + return + case "/api/openapi/client.yaml": + w.Header().Set("Content-Type", "application/x-yaml") + w.Write(clientoapi.OAPI) //nolint:errcheck + return + case "/api", "/api/": + content, err := scalar.FS.ReadFile("index.html") + if err != nil { + nethttp.Error(w, "Internal Server Error", nethttp.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(content) //nolint:errcheck + return + } + + next.ServeHTTP(w, req) + }) + } +} diff --git a/internal/http/controllers/v1/management/http.go b/internal/http/controllers/v1/management/http.go deleted file mode 100644 index 3042d255..00000000 --- a/internal/http/controllers/v1/management/http.go +++ /dev/null @@ -1,62 +0,0 @@ -package v1 - -import ( - _ "embed" - "fmt" - - "github.com/cloudproud/graceful" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/go-chi/chi/v5" - "github.com/jmoiron/sqlx" - "github.com/lunogram/platform/internal/config" - "github.com/lunogram/platform/internal/http" - "github.com/lunogram/platform/internal/http/auth" - "github.com/lunogram/platform/internal/http/console" - "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" - "github.com/lunogram/platform/internal/providers" - "github.com/lunogram/platform/internal/pubsub" - "github.com/lunogram/platform/internal/storage" - "github.com/lunogram/platform/internal/store/management" - "go.uber.org/zap" -) - -// NewServer constructs a new HTTP server and it's routes. The returned server -// could be used to listen and serve incoming requests on the given address. -func NewServer(ctx graceful.Context, logger *zap.Logger, config config.Node, db *sqlx.DB, storage storage.Storage, pub pubsub.Publisher, registry *providers.Registry) (*http.Server, error) { - spec, err := oapi.Spec() - if err != nil { - return nil, fmt.Errorf("failed to load OpenAPI spec: %w", err) - } - - stores := management.NewState(db) - - controller, err := NewController(logger, db, config, storage, pub, registry) - if err != nil { - return nil, fmt.Errorf("failed to create controller: %w", err) - } - - options := openapi3filter.Options{ - AuthenticationFunc: auth.Middleware( - auth.WithJWT(config.Auth, stores), - auth.WithKey(stores), - ), - } - - router := chi.NewRouter() - router.Use(http.Logger(logger)) - - router.Use(oapi.Scalar()) - - oapi.HandlerWithOptions(controller, oapi.ChiServerOptions{ - BaseRouter: router, - Middlewares: []oapi.MiddlewareFunc{oapi.Validator(spec, options)}, - }) - - consoleHandler, err := console.Handler() - if err != nil { - return nil, fmt.Errorf("failed to create console handler: %w", err) - } - router.Handle("/*", consoleHandler) - - return http.NewServer(logger, router, config.HTTP), nil -} diff --git a/internal/http/controllers/v1/management/oapi/middleware.go b/internal/http/controllers/v1/management/oapi/middleware.go index 57a98c83..9c369b01 100644 --- a/internal/http/controllers/v1/management/oapi/middleware.go +++ b/internal/http/controllers/v1/management/oapi/middleware.go @@ -7,7 +7,6 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/lunogram/platform/internal/http/problem" - "github.com/lunogram/platform/internal/http/scalar" middleware "github.com/oapi-codegen/nethttp-middleware" ) @@ -21,26 +20,3 @@ func Validator(spec *openapi3.T, options openapi3filter.Options) func(next http. }, }) } - -func Scalar() func(next http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/api/openapi.yaml" { - scalar.HandleOAPI(oapi).ServeHTTP(w, req) - return - } - if req.URL.Path == "/api" || req.URL.Path == "/api/" { - content, err := scalar.FS.ReadFile("index.html") - if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(content) //nolint:errcheck - return - } - - next.ServeHTTP(w, req) - }) - } -} diff --git a/internal/http/controllers/v1/management/oapi/oapi.go b/internal/http/controllers/v1/management/oapi/oapi.go index 40bb3de3..f2223d8e 100644 --- a/internal/http/controllers/v1/management/oapi/oapi.go +++ b/internal/http/controllers/v1/management/oapi/oapi.go @@ -13,10 +13,10 @@ import ( //go:generate oapi-codegen -o ./resources_gen.go -generate types,client,chi-server -package oapi ./resources.yml //go:embed resources.yml -var oapi []byte +var OAPI []byte func Spec() (*openapi3.T, error) { - return openapi3.NewLoader().LoadFromData(oapi) + return openapi3.NewLoader().LoadFromData(OAPI) } // WriteProblem writes a JSON v1 problem message to the given response writer. diff --git a/internal/http/controllers/v1/public/http.go b/internal/http/controllers/v1/public/http.go deleted file mode 100644 index 57b139ba..00000000 --- a/internal/http/controllers/v1/public/http.go +++ /dev/null @@ -1,62 +0,0 @@ -package v1 - -import ( - "embed" - "fmt" - "io/fs" - nethttp "net/http" - - "github.com/cloudproud/graceful" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/go-chi/chi/v5" - "github.com/jmoiron/sqlx" - "github.com/lunogram/platform/internal/config" - "github.com/lunogram/platform/internal/http" - "github.com/lunogram/platform/internal/http/auth" - "github.com/lunogram/platform/internal/http/controllers/v1/public/oapi" - "github.com/lunogram/platform/internal/pubsub" - "github.com/lunogram/platform/internal/storage" - "github.com/lunogram/platform/internal/store/management" - "github.com/lunogram/platform/internal/store/users" - "go.uber.org/zap" -) - -//go:embed static -var staticFiles embed.FS - -// NewServer constructs a new HTTP server and its routes. The returned server -// could be used to listen and serve incoming requests on the given address. -func NewServer(ctx graceful.Context, logger *zap.Logger, config config.Node, db *sqlx.DB, mgmt *management.State, usrs *users.State, storage storage.Storage, pub pubsub.Publisher) (*http.Server, error) { - spec, err := oapi.Spec() - if err != nil { - return nil, fmt.Errorf("failed to load OpenAPI spec: %w", err) - } - - options := openapi3filter.Options{ - AuthenticationFunc: auth.Middleware(auth.WithJWT(config.Auth, mgmt), auth.WithKey(mgmt)), - } - - router := chi.NewRouter() - router.Use(http.Logger(logger)) - - router.Use(oapi.Scalar()) - - // Serve static files for CSS - use sub-filesystem to strip the "static" prefix - staticSubFS, err := fs.Sub(staticFiles, "static") - if err != nil { - return nil, fmt.Errorf("failed to create static sub-filesystem: %w", err) - } - router.Handle("/static/*", nethttp.StripPrefix("/static/", nethttp.FileServer(nethttp.FS(staticSubFS)))) - - controller, err := NewController(logger, db, mgmt, usrs, pub) - if err != nil { - return nil, fmt.Errorf("failed to create controller: %w", err) - } - - oapi.HandlerWithOptions(controller, oapi.ChiServerOptions{ - BaseRouter: router, - Middlewares: []oapi.MiddlewareFunc{oapi.Validator(spec, options)}, - }) - - return http.NewServer(logger, router, config.HTTP), nil -} diff --git a/internal/http/scalar/index.html b/internal/http/scalar/index.html index 6a1c5210..f6fe9ce2 100644 --- a/internal/http/scalar/index.html +++ b/internal/http/scalar/index.html @@ -15,8 +15,18 @@ From 5a50c9f1a85af45ff1953e0ab2d00b63282c0992 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 5 Feb 2026 23:55:47 +0100 Subject: [PATCH 2/3] Update internal/http/controllers/v1/http.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/http/controllers/v1/http.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/http/controllers/v1/http.go b/internal/http/controllers/v1/http.go index bfa74727..d624e2b0 100644 --- a/internal/http/controllers/v1/http.go +++ b/internal/http/controllers/v1/http.go @@ -87,7 +87,7 @@ func NewServer(ctx graceful.Context, logger *zap.Logger, cfg config.Node, db *sq })}, }) - // Serve static files for CSS - use sub-filesystem to strip the "client/static" prefix + // Serve static assets - use sub-filesystem to strip the "client/static" prefix staticSubFS, err := fs.Sub(staticFiles, "client/static") if err != nil { return nil, fmt.Errorf("failed to create static sub-filesystem: %w", err) From aa144b45fd717009a2bb7e5d21caa8294f36ccd3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 00:02:31 +0100 Subject: [PATCH 3/3] Clarify Scope struct serves both JWT and API key authentication (#149) * Update Scope comment to clarify dual authentication purpose Co-authored-by: jeroenrinzema <3440116+jeroenrinzema@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeroenrinzema <3440116+jeroenrinzema@users.noreply.github.com> --- internal/claim/rbac/rbac.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/claim/rbac/rbac.go b/internal/claim/rbac/rbac.go index 79e1befc..26b8c280 100644 --- a/internal/claim/rbac/rbac.go +++ b/internal/claim/rbac/rbac.go @@ -10,7 +10,8 @@ type contextKey string const scopeKey contextKey = "admin" -// Scope represents an authenticated user in the context +// Scope represents an authenticated user or API key in the context. +// It is used for both the management API (JWT authentication) and the client API (API key authentication). type Scope struct { OrganizationID uuid.UUID ProjectID uuid.UUID