diff --git a/cmd/procyon/version.go b/cmd/procyon/version.go index a4faa2c..8804d37 100644 --- a/cmd/procyon/version.go +++ b/cmd/procyon/version.go @@ -15,8 +15,9 @@ package main import ( - "codnect.io/procyon" "fmt" + + "codnect.io/procyon" "github.com/spf13/cobra" ) diff --git a/component/singleton.go b/component/singleton.go index 584a1cf..e53c10a 100644 --- a/component/singleton.go +++ b/component/singleton.go @@ -14,6 +14,8 @@ package component +import "context" + // SingletonRegistry defines methods for managing singleton instances within the component system. type SingletonRegistry interface { // RegisterSingleton registers a singleton instance with the given name. @@ -36,3 +38,9 @@ type SingletonRegistry interface { // SingletonNames returns a slice of all registered singleton names. SingletonNames() []string } + +// AfterSingleton is called once after singleton initialization, before lifecycle +// startup. Callbacks must not rely on the order of other completion callbacks. +type AfterSingleton interface { + SingletonsInitialized(ctx context.Context) error +} diff --git a/config.go b/config.go index 28c6095..b1c47ea 100644 --- a/config.go +++ b/config.go @@ -17,10 +17,14 @@ package procyon import ( "context" + "codnect.io/procyon/component" "codnect.io/procyon/runtime" "codnect.io/procyon/runtime/config" ) +// Compile-time check that configPropertiesProcessor implements component.AfterInitProcessor. +var _ component.AfterInitProcessor = (*configPropertiesProcessor)(nil) + // configPropertiesProcessor binds configuration properties to components // that implement the config.Properties interface. type configPropertiesProcessor struct { @@ -41,7 +45,7 @@ func newConfigPropertiesProcessor(env runtime.Environment) *configPropertiesProc // ProcessAfterInit binds configuration properties to the given component // if it implements the config.Properties interface. -func (c *configPropertiesProcessor) ProcessAfterInit(_ context.Context, instance any) (any, error) { +func (c *configPropertiesProcessor) ProcessAfterInit(_ context.Context, _ string, instance any) (any, error) { if properties, ok := instance.(config.Properties); ok { binder := config.NewDefaultPropertyBinder(c.env.PropertySources()) diff --git a/context.go b/context.go index 0249e11..5bc17a9 100644 --- a/context.go +++ b/context.go @@ -269,6 +269,10 @@ func (c *Context) doRefresh(ctx context.Context) (err error) { return err } + if err = c.invokeAfterSingletons(ctx); err != nil { + return err + } + if err = c.resolveLifecycleManager(ctx); err != nil { return err } @@ -529,6 +533,27 @@ func (c *Context) initializeSingletons(ctx context.Context) error { return nil } +// invokeAfterSingletons invokes completion callbacks on singleton components +// whose definitions implement AfterSingleton. Callback order is unspecified. +func (c *Context) invokeAfterSingletons(ctx context.Context) error { + definitions := c.container.DefinitionsOf(reflect.TypeFor[component.AfterSingleton]()) + for _, definition := range definitions { + if !definition.IsSingleton() { + continue + } + name := definition.Name() + instance, err := c.container.Resolve(ctx, name) + if err != nil { + return fmt.Errorf("resolve after-singleton component %q: %w", name, err) + } + callback := instance.(component.AfterSingleton) + if err := callback.SingletonsInitialized(ctx); err != nil { + return fmt.Errorf("after singletons initialized %q: %w", name, err) + } + } + return nil +} + // cancelRefresh rolls back a failed refresh attempt by stopping lifecycle management, destroying initialized // singletons, and clearing context state. func (c *Context) cancelRefresh(ctx context.Context) error { diff --git a/go.mod b/go.mod index a9aa79c..3ce6abe 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module codnect.io/procyon -go 1.24.0 +go 1.27.0 require ( codnect.io/logy v1.0.2 @@ -16,6 +16,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.35.0 // indirect + github.com/xo/terminfo v1.2.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 3fcfb5e..5236e9e 100644 --- a/go.sum +++ b/go.sum @@ -20,10 +20,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= +github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/http/context.go b/http/context.go index aa60261..530279d 100644 --- a/http/context.go +++ b/http/context.go @@ -15,19 +15,11 @@ package http import ( - "context" "errors" "net/http" "time" ) -type serverContext interface { - context.Context - SetValue(key, value any) - Request() *ServerRequest - Response() *ServerResponse -} - // Context represents the context for an HTTP request and response. type Context struct { endpoint *Endpoint @@ -117,6 +109,13 @@ func (c *Context) SetEndpoint(endpoint *Endpoint) { // reset clears the context state and assigns a new HTTP request and response writer. func (c *Context) reset(r *http.Request, w http.ResponseWriter) { c.err = nil + c.endpoint = nil + c.req.ctx = c + c.res.ctx = c + c.req.pathValues.reset() + if c.res.headers == nil { + c.res.headers = make(Header) + } clear(c.values) c.req.nativeReq = r @@ -130,6 +129,10 @@ func (c *Context) reset(r *http.Request, w http.ResponseWriter) { clear(c.res.headers) } +func (c *Context) private() { + +} + // EndpointContext represents a typed context for an HTTP endpoint handler. // It wraps the base Context and provides access to parsed input data // from path parameters, query strings, and request body. @@ -190,3 +193,7 @@ func (e *EndpointContext[I]) NativeContext() *Context { func (e *EndpointContext[I]) setContext(ctx *Context) { e.ctx = ctx } + +func (e *EndpointContext[I]) private() { + +} diff --git a/http/dispatcher.go b/http/dispatcher.go index 677667b..a29e2fe 100644 --- a/http/dispatcher.go +++ b/http/dispatcher.go @@ -14,6 +14,11 @@ package http +import ( + "context" + "fmt" +) + // Dispatcher interface represents a dispatcher that can process // an HTTP request contained in the Context. type Dispatcher interface { @@ -21,7 +26,32 @@ type Dispatcher interface { } type RequestDispatcher struct { - delegate RequestDelegate + delegate RequestDelegate + dataSource *endpointDataSource + middlewares []Middleware +} + +// newRequestDispatcher defers pipeline construction until mapping is complete. +func newRequestDispatcher(source *endpointDataSource, middlewares []Middleware) *RequestDispatcher { + return &RequestDispatcher{dataSource: source, middlewares: middlewares} +} + +func (d *RequestDispatcher) SingletonsInitialized(ctx context.Context) error { + if d.delegate != nil { + return nil + } + d.dataSource.sealed = true + matcher, err := buildEndpointMatcher(d.dataSource) + if err != nil { + return err + } + for _, middleware := range d.middlewares { + if isNilResultValue(middleware) { + return fmt.Errorf("nil HTTP middleware") + } + } + d.delegate = buildPipeline(matcher, d.middlewares...) + return nil } // NewRequestDispatcher creates a new dispatcher by building @@ -46,6 +76,9 @@ func NewRequestDispatcher(endpointMatcher EndpointMatcher, middlewares ...Middle // Dispatch executes the built pipeline for the given request context. func (d *RequestDispatcher) Dispatch(ctx *Context) error { + if d.delegate == nil { + return fmt.Errorf("HTTP dispatcher is not initialized") + } return d.delegate(ctx) } diff --git a/http/endpoint.go b/http/endpoint.go index 67e99f3..669cb17 100644 --- a/http/endpoint.go +++ b/http/endpoint.go @@ -15,6 +15,8 @@ package http import ( + "context" + "fmt" "path" "strings" ) @@ -71,12 +73,26 @@ type EndpointDataSource interface { type endpointDataSource struct { endpoints []*Endpoint + sealed bool +} + +func newEndpointDataSource() *endpointDataSource { + return &endpointDataSource{} } func NewEndpointDataSource(endpoints ...*Endpoint) EndpointDataSource { return &endpointDataSource{endpoints: endpoints} } +// register collects executable endpoints before the dispatcher is initialized. +func (s *endpointDataSource) register(endpoint *Endpoint) error { + if s.sealed { + return fmt.Errorf("endpoint mapping has already completed") + } + s.endpoints = append(s.endpoints, endpoint) + return nil +} + func (s *endpointDataSource) Endpoints() []*Endpoint { return s.endpoints } @@ -159,7 +175,13 @@ func newEndpointGroup(prefix string) *EndpointGroup { // MapAny maps a handler function to the specified path for all HTTP methods within the group. func (g *EndpointGroup) MapAny(path string, handler Handler) *EndpointBuilder { - return g.MapMethods(path, nil, handler) + return g.MapMethods(path, []Method{ + MethodGet, + MethodPost, + MethodPut, + MethodDelete, + MethodPatch, + }, handler) } // MapMethods maps a handler function to the specified path for the given HTTP methods within the group. @@ -204,6 +226,70 @@ func (g *EndpointGroup) MapGroup(prefix string) *EndpointGroup { return group } +// endpointMappingProcessor collects mappings after the configurer's Init hook +// has completed, so mappings can use initialized component state. +type endpointMappingProcessor struct { + dataSource *endpointDataSource + executors *ResultExecutorRegistry +} + +func newEndpointMappingProcessor(dataSource *endpointDataSource, executors *ResultExecutorRegistry) *endpointMappingProcessor { + if dataSource == nil { + panic("nil endpoint data source") + } + + return &endpointMappingProcessor{dataSource: dataSource, executors: executors} +} + +func (p *endpointMappingProcessor) ProcessAfterInit(ctx context.Context, name string, instance any) (any, error) { + configurer, ok := instance.(EndpointConfigurer) + if !ok { + return instance, nil + } + + group := newEndpointGroup("/") + configurer.ConfigureEndpoints(group) + + if err := p.collect(group); err != nil { + return nil, fmt.Errorf("map endpoints for %q: %w", name, err) + } + return instance, nil +} + +func (p *endpointMappingProcessor) collect(group *EndpointGroup) error { + for _, route := range group.routes { + if isNilResultValue(route.handler) { + return fmt.Errorf("nil handler for %s", route.path) + } + handler := route.handler + delegate := RequestDelegate(func(ctx *Context) error { + result, err := handler.Handle(ctx) + if err != nil { + return err + } + if isNilResultValue(result) { + return nil + } + executor, ok := p.executors.Resolve(result) + if !ok { + return fmt.Errorf("no result executor for %T", result) + } + return executor.Execute(ctx, result) + }) + for _, method := range route.methods { + if err := p.dataSource.register(NewEndpoint(method, route.path, delegate)); err != nil { + return err + } + } + } + for _, child := range group.children { + if err := p.collect(child); err != nil { + return err + } + } + return nil +} + // joinPaths joins multiple path elements into a single path string, // ensuring that there is exactly one '/' separator between elements // and preserving leading and trailing slashes. diff --git a/http/handler.go b/http/handler.go index daab9ca..7ab1b4a 100644 --- a/http/handler.go +++ b/http/handler.go @@ -14,6 +14,18 @@ package http +import "context" + +type handlerContext[T any] interface { + *T + context.Context + + SetValue(key, value any) + Request() *ServerRequest + Response() *ServerResponse + private() +} + // Handler represents an HTTP request handler that processes // an incoming request and returns a Result or an error. type Handler interface { @@ -29,18 +41,15 @@ func (f HandlerFunc) Handle(ctx *Context) (Result, error) { return f(ctx) } -// typedHandler wraps a function that takes a typed context and returns only an error. +// handlerAdapter wraps a function that takes a typed context and returns only an error. // Used internally when the handler doesn't need to return a Result. -type typedHandler[T any, C interface { - *T - serverContext -}] struct { +type handlerAdapter[T any, C handlerContext[T]] struct { fn func(C) error } // Handle creates a typed context, binds the base Context to it, // and executes the handler function. -func (h *typedHandler[T, C]) Handle(ctx *Context) (Result, error) { +func (h *handlerAdapter[T, C]) Handle(ctx *Context) (Result, error) { endpointCtx := C(new(T)) if provider, ok := any(endpointCtx).(interface{ setContext(*Context) }); ok { provider.setContext(ctx) @@ -48,18 +57,15 @@ func (h *typedHandler[T, C]) Handle(ctx *Context) (Result, error) { return nil, h.fn(endpointCtx) } -// typedResultHandler wraps a function that takes a typed context and returns a Result. +// handlerResultAdapter wraps a function that takes a typed context and returns a Result. // Used internally for handlers that return structured responses. -type typedResultHandler[T any, C interface { - *T - serverContext -}, R Result] struct { +type handlerResultAdapter[T any, C handlerContext[T], R Result] struct { fn func(C) (R, error) } // Handle creates a typed context, binds the base Context to it, // and executes the handler function, returning the Result. -func (h *typedResultHandler[T, C, R]) Handle(ctx *Context) (Result, error) { +func (h *handlerResultAdapter[T, C, R]) Handle(ctx *Context) (Result, error) { endpointCtx := C(new(T)) if provider, ok := any(endpointCtx).(interface{ setContext(*Context) }); ok { provider.setContext(ctx) @@ -69,28 +75,24 @@ func (h *typedResultHandler[T, C, R]) Handle(ctx *Context) (Result, error) { // Handle creates a Handler from a function that returns only an error. // Type parameters are inferred from the function signature. -func Handle[T any, C interface { - *T - serverContext -}](fn func(C) error) Handler { +func Handle[T any, C handlerContext[T]](fn func(C) error) Handler { + if _, ok := any((*T)(nil)).(*Context); ok { return HandlerFunc(func(ctx *Context) (Result, error) { return nil, fn(any(ctx).(C)) }) } - return &typedHandler[T, C]{fn: fn} + return &handlerAdapter[T, C]{fn: fn} } // HandleResult creates a Handler from a function that returns a Result. // Type parameters are inferred from the function signature. -func HandleResult[T any, C interface { - *T - serverContext -}, R Result](fn func(C) (R, error)) Handler { +func HandleResult[T any, C handlerContext[T], R Result](fn func(C) (R, error)) Handler { if _, ok := any((*T)(nil)).(*Context); ok { return HandlerFunc(func(ctx *Context) (Result, error) { return fn(any(ctx).(C)) }) } - return &typedResultHandler[T, C, R]{fn: fn} + + return &handlerResultAdapter[T, C, R]{fn: fn} } diff --git a/http/init.go b/http/init.go index a919212..1c947ab 100644 --- a/http/init.go +++ b/http/init.go @@ -18,4 +18,10 @@ import "codnect.io/procyon/component" func init() { component.Register(newServerProperties) + component.Register(newEndpointDataSource) + component.Register(newEndpointMappingProcessor) + component.Register(newResultExecutorRegistry) + component.Register(newRequestDispatcher) + component.Register(newServer) + component.Register(newServerLifecycle) } diff --git a/http/matcher.go b/http/matcher.go index d19da33..123eaf6 100644 --- a/http/matcher.go +++ b/http/matcher.go @@ -110,31 +110,39 @@ func (n *radixNode) findChild(b byte) (*radixNode, bool) { return nil, false } -// RequestEndpointMatcher is a router implementation based on a radix tree. -type RequestEndpointMatcher struct { +// radixEndpointMatcher is a router implementation based on a radix tree. +type radixEndpointMatcher struct { root *radixNode } -// NewRequestEndpointMatcher creates a new empty radix-tree router. -func NewRequestEndpointMatcher(endpointDataSource EndpointDataSource) *RequestEndpointMatcher { - matcher := &RequestEndpointMatcher{root: &radixNode{}} - - if endpointDataSource == nil { - return matcher +// NewEndpointMatcher creates a new empty radix-tree router. +func NewEndpointMatcher(endpointDataSource EndpointDataSource) EndpointMatcher { + matcher, err := buildEndpointMatcher(endpointDataSource) + if err != nil { + panic(err.Error()) } + return matcher +} - for _, endpoint := range endpointDataSource.Endpoints() { +func buildEndpointMatcher(source EndpointDataSource) (EndpointMatcher, error) { + matcher := &radixEndpointMatcher{root: &radixNode{}} + if source == nil { + return matcher, nil + } + for _, endpoint := range source.Endpoints() { + if endpoint == nil { + return nil, fmt.Errorf("nil endpoint") + } if err := matcher.addEndpoint(endpoint); err != nil { - panic(fmt.Sprintf("failed to add endpoint %s %s: %v", endpoint.method, endpoint.path, err)) + return nil, fmt.Errorf("failed to add endpoint %s %s: %w", endpoint.method, endpoint.path, err) } } - - return matcher + return matcher, nil } // insertStatic inserts a static path fragment into the radix tree. // The function performs prefix compression and splits nodes when necessary. -func (t *RequestEndpointMatcher) insertStatic(n *radixNode, path string) *radixNode { +func (t *radixEndpointMatcher) insertStatic(n *radixNode, path string) *radixNode { for { if len(path) == 0 { @@ -193,7 +201,7 @@ func (t *RequestEndpointMatcher) insertStatic(n *radixNode, path string) *radixN } // addEndpoint registers a new endpoint into the radix tree. -func (t *RequestEndpointMatcher) addEndpoint(endpoint *Endpoint) error { +func (t *radixEndpointMatcher) addEndpoint(endpoint *Endpoint) error { if methodIndex(endpoint.method) < 0 { return fmt.Errorf("unsupported HTTP method: %s", endpoint.method) } @@ -327,7 +335,7 @@ func (t *RequestEndpointMatcher) addEndpoint(endpoint *Endpoint) error { } // match recursively matches the request path against the radix tree. -func (t *RequestEndpointMatcher) match(n *radixNode, path string, ctx *Context, mi int) *radixNode { +func (t *radixEndpointMatcher) match(n *radixNode, path string, ctx *Context, mi int) *radixNode { request := ctx.Request() for { @@ -482,7 +490,7 @@ func (t *RequestEndpointMatcher) match(n *radixNode, path string, ctx *Context, } // Match resolves the incoming request to a registered endpoint. -func (t *RequestEndpointMatcher) Match(ctx *Context) (*Endpoint, bool) { +func (t *radixEndpointMatcher) Match(ctx *Context) (*Endpoint, bool) { request := ctx.Request() path := request.Path() @@ -498,6 +506,9 @@ func (t *RequestEndpointMatcher) Match(ctx *Context) (*Endpoint, bool) { mi := methodIndex(request.Method()) request.pathValues.reset() + if mi < 0 { + return nil, false + } node := t.match(t.root, path[1:], ctx, mi) diff --git a/http/middleware.go b/http/middleware.go index 7721a32..82cb7ff 100644 --- a/http/middleware.go +++ b/http/middleware.go @@ -45,11 +45,8 @@ func newRoutingMiddleware(matcher EndpointMatcher) *routingMiddleware { // If a matching endpoint is found, it is set on the context for later use. // The next delegate is always called regardless of whether a match was found. func (r *routingMiddleware) Invoke(ctx *Context, next RequestDelegate) error { - endpoint, ok := r.matcher.Match(ctx) - - if ok { - ctx.SetEndpoint(endpoint) - } + endpoint, _ := r.matcher.Match(ctx) + ctx.SetEndpoint(endpoint) return next(ctx) } diff --git a/http/result.go b/http/result.go index 62897f0..b71586d 100644 --- a/http/result.go +++ b/http/result.go @@ -14,44 +14,461 @@ package http +import "io" + // Result represents an HTTP response produced by a handler. type Result interface { - // StatusCode returns the HTTP status code of the response. - StatusCode() Status - // BodyValue returns the body value of the response. - BodyValue() any + // Status returns the HTTP status code of the response. + Status() Status // Header returns the HTTP headers of the response. Header() Header } -// TypedResult represents a typed HTTP response with a generic body type. -// It implements the Result interface and provides a convenient way -// to return structured responses from handlers. -type TypedResult[T any] struct { - // Body contains the response body that will be serialized. - Body T - // Status is the HTTP status code of the response. - Status Status - // Header contains the HTTP headers to be sent with the response. - Headers Header +type ValueResult interface { + Result + + Value() any +} + +type StatusCodeResult struct { + StatusCode Status + Headers Header +} + +func StatusCode(status Status) StatusCodeResult { + return StatusCodeResult{ + StatusCode: status, + } +} + +func NoContent() StatusCodeResult { + return StatusCodeResult{ + StatusCode: StatusNoContent, + } +} + +func (s StatusCodeResult) Status() Status { + return s.StatusCode +} + +func (s StatusCodeResult) Header() Header { + return s.Headers } -// StatusCode returns the HTTP status code of the response. -// If Status is not set, it defaults to StatusOK. -func (t TypedResult[T]) StatusCode() Status { - if t.Status == 0 { - return StatusOK +type BodyResult[T any] struct { + Body T + StatusCode Status + Headers Header +} + +func Ok[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusOK, + Headers: Header{}, } - return t.Status } -// BodyValue returns the body as an any type for serialization. -func (t TypedResult[T]) BodyValue() any { - return t.Body +func Accepted[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusAccepted, + Headers: Header{}, + } +} + +func Created[T any](location string, body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusCreated, + Headers: Header{ + "Location": []string{location}, + }, + } +} + +func BadRequest[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusBadRequest, + Headers: Header{}, + } +} + +func Unauthorized[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusUnauthorized, + Headers: Header{}, + } +} + +func Forbidden[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusForbidden, + Headers: Header{}, + } +} + +func NotFound[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusNotFound, + Headers: Header{}, + } +} + +func Conflict[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusConflict, + Headers: Header{}, + } +} + +func UnprocessableEntity[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusUnprocessableEntity, + Headers: Header{}, + } +} + +func InternalServerError[T any](body T) BodyResult[T] { + return BodyResult[T]{ + Body: body, + StatusCode: StatusInternalServerError, + Headers: Header{}, + } +} + +func (b BodyResult[T]) Header() Header { + return b.Headers +} + +func (b BodyResult[T]) Status() Status { + return b.StatusCode +} + +func (b BodyResult[T]) Value() any { + return b.Body +} + +type RedirectResult struct { + Location string + StatusCode Status +} + +func Redirect(url string) RedirectResult { + return RedirectResult{ + Location: url, + StatusCode: StatusFound, + } +} + +func RedirectPermanent(url string) RedirectResult { + return RedirectResult{ + Location: url, + StatusCode: StatusMovedPermanently, + } +} + +func RedirectPreserveMethod(url string) RedirectResult { + return RedirectResult{ + Location: url, + StatusCode: StatusTemporaryRedirect, + } +} + +func RedirectPermanentPreserveMethod(url string) RedirectResult { + return RedirectResult{ + Location: url, + StatusCode: StatusPermanentRedirect, + } +} + +func (r RedirectResult) Status() Status { + return r.StatusCode +} + +func (r RedirectResult) Header() Header { + return Header{ + "Location": []string{r.Location}, + } +} + +type ContentResult struct { + Content string + ContentType string + StatusCode Status +} + +func Text(content string) ContentResult { + return ContentResult{ + Content: content, + ContentType: "text/plain", + StatusCode: StatusOK, + } +} + +func Content(content, contentType string) ContentResult { + return ContentResult{ + Content: content, + ContentType: contentType, + StatusCode: StatusOK, + } +} + +func Html(content string) ContentResult { + return ContentResult{ + Content: content, + ContentType: "text/html", + StatusCode: StatusOK, + } +} + +func (c ContentResult) Status() Status { + return c.StatusCode +} + +func (c ContentResult) Value() any { + return c.Content +} + +func (c ContentResult) Header() Header { + return Header{ + "Content-Type": []string{c.ContentType}, + } +} + +type JsonResult struct { + Data any + StatusCode Status + Headers Header +} + +func Json(data any, status Status) JsonResult { + return JsonResult{ + Data: data, + StatusCode: status, + Headers: Header{"Content-Type": []string{"application/json"}}, + } +} + +func (j JsonResult) Status() Status { + return j.StatusCode +} + +func (j JsonResult) Value() any { + return j.Data +} + +func (j JsonResult) Header() Header { + return j.Headers +} + +type FileDisposition string + +const ( + FileDispositionInline FileDisposition = "inline" + FileDispositionAttachment FileDisposition = "attachment" +) + +type FileResult interface { + Result + + MediaType() string + FileDownloadName() string + FileDisposition() FileDisposition +} + +type FileContentResult struct { + Content []byte + ContentType string + DownloadName string + Disposition FileDisposition + StatusCode Status + EntityTag string + LastModified string +} + +func File(content []byte, contentType string) FileContentResult { + return FileContentResult{ + Content: content, + ContentType: contentType, + DownloadName: "", + StatusCode: StatusOK, + } +} + +func FileInline(content []byte, contentType string) FileContentResult { + return FileContentResult{ + Content: content, + ContentType: contentType, + DownloadName: "", + Disposition: FileDispositionInline, + StatusCode: StatusOK, + } +} + +func FileAttachment(content []byte, contentType, downloadName string) FileContentResult { + return FileContentResult{ + Content: content, + ContentType: contentType, + DownloadName: downloadName, + Disposition: FileDispositionAttachment, + StatusCode: StatusOK, + } +} + +func (f FileContentResult) Status() Status { + return f.StatusCode +} + +func (f FileContentResult) Header() Header { + + h := Header{ + "Content-Type": []string{f.ContentType}, + } + if f.Disposition != "" { + value := string(f.Disposition) + if f.DownloadName != "" { + value += `; filename="` + f.DownloadName + `"` + } + h.Set("Content-Disposition", value) + } + if f.EntityTag != "" { + h.Set("ETag", f.EntityTag) + } + if f.LastModified != "" { + h.Set("Last-Modified", f.LastModified) + } + return h + +} +func (f FileContentResult) Value() any { + return f.Content +} + +func (f FileContentResult) MediaType() string { + return f.ContentType +} + +func (f FileContentResult) FileDownloadName() string { + return f.DownloadName +} + +func (f FileContentResult) FileDisposition() FileDisposition { + return f.Disposition +} + +type PhysicalFileResult struct { + FilePath string + ContentType string + DownloadName string + Disposition FileDisposition + StatusCode Status +} + +func PhysicalFile(filePath, contentType string) PhysicalFileResult { + return PhysicalFileResult{ + FilePath: filePath, + ContentType: contentType, + StatusCode: StatusOK, + } +} + +func (f PhysicalFileResult) Status() Status { + return f.StatusCode +} + +func (f PhysicalFileResult) Header() Header { + h := Header{ + "Content-Type": []string{f.ContentType}, + } + if f.Disposition != "" { + value := string(f.Disposition) + if f.DownloadName != "" { + value += `; filename="` + f.DownloadName + `"` + } + h.Set("Content-Disposition", value) + } + return h +} + +func (f PhysicalFileResult) MediaType() string { + return f.ContentType +} + +func (f PhysicalFileResult) FileDownloadName() string { + return f.DownloadName +} + +func (f PhysicalFileResult) FileDisposition() FileDisposition { + return f.Disposition +} + +type StreamResult struct { + Reader io.Reader + ContentType string + StatusCode Status + Headers Header +} + +func Stream(reader io.Reader, contentType string) StreamResult { + return StreamResult{ + Reader: reader, + ContentType: contentType, + StatusCode: StatusOK, + } +} + +func (s StreamResult) Status() Status { + return s.StatusCode +} + +func (s StreamResult) Value() any { + return s.Reader +} + +func (s StreamResult) Header() Header { + h := s.Headers.Clone() + if h == nil { + h = make(Header) + } + h.Set("Content-Type", s.ContentType) + return h +} + +func Problem() Result { + return nil +} + +func ValidationProblem() Result { + return nil +} + +type ResultExecutorRegistry struct { + executors []ResultExecutor +} + +func (r *ResultExecutorRegistry) Register(executor ResultExecutor) { + r.executors = append(r.executors, executor) + +} + +func (r *ResultExecutorRegistry) Resolve(result Result) (ResultExecutor, bool) { + for _, executor := range r.executors { + if executor.CanExecute(result) { + return executor, true + } + } + return nil, false } -// Header returns the HTTP headers of the response. -func (t TypedResult[T]) Header() Header { - return t.Headers +type ResultExecutor interface { + CanExecute(result Result) bool + Execute(ctx *Context, result Result) error } diff --git a/http/result_test.go b/http/result_test.go index bf2cf86..7618a43 100644 --- a/http/result_test.go +++ b/http/result_test.go @@ -42,7 +42,7 @@ func TestTypedResult_StatusCode(t *testing.T) { // given // when - typedResult := TypedResult[any]{ + typedResult := BodyResult[any]{ Status: tc.status, } @@ -57,7 +57,7 @@ func TestTypedResult_BodyValue(t *testing.T) { body := any("anyBody") // when - typedResult := TypedResult[any]{ + typedResult := BodyResult[any]{ Body: body, } @@ -70,7 +70,7 @@ func TestTypedResult_Header(t *testing.T) { headers := Header{} // when - typedResult := TypedResult[any]{ + typedResult := BodyResult[any]{ Headers: headers, } diff --git a/http/server.go b/http/server.go index ff40324..5401130 100644 --- a/http/server.go +++ b/http/server.go @@ -16,9 +16,13 @@ package http import ( "context" + "errors" "fmt" + "net" "net/http" "sync" + + "codnect.io/logy" ) // ServerProperties defines the configuration properties for the Server component. @@ -34,17 +38,6 @@ func (s *ServerProperties) Prefix() string { return "server" } -// stdServer abstracts http.Server to allow Server to be tested -// without starting a real HTTP listener. -type stdServer interface { - // ListenAndServe starts the HTTP server and begins accepting requests. - - ListenAndServe() error - // Shutdown gracefully stops the HTTP server without interrupting - // active connections. - Shutdown(ctx context.Context) error -} - // Server is the HTTP server that listens for incoming requests and // dispatches them through the configured Dispatcher. // @@ -52,9 +45,13 @@ type stdServer interface { // to minimize allocations per request. type Server struct { props ServerProperties - httpServer stdServer + httpServer *http.Server contextPool sync.Pool dispatcher Dispatcher + mu sync.RWMutex + running bool + stopping bool + boundPort int } // NewServer creates a new Server with the given properties and dispatcher. @@ -80,31 +77,75 @@ func NewServer(props ServerProperties, dispatcher Dispatcher) *Server { } } -// Start begins listening for HTTP requests on the configured port. -// It blocks until the server is shut down or an error occurs. +// newServer adapts the pointer properties component to the public constructor. +func newServer(props *ServerProperties, dispatcher Dispatcher) *Server { + return NewServer(*props, dispatcher) +} + +// Start binds synchronously, then serves in the background. A bind failure is +// returned before lifecycle startup succeeds. func (s *Server) Start(ctx context.Context) error { - if s.httpServer == nil { - s.httpServer = &http.Server{ - Addr: fmt.Sprintf(":%d", s.props.Port), - Handler: s, - } + s.mu.Lock() + defer s.mu.Unlock() + if s.stopping { + return fmt.Errorf("HTTP server is stopping") } - - if err := s.httpServer.ListenAndServe(); err != nil { + if s.running { + return nil + } + if err := ctx.Err(); err != nil { return err } - + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", fmt.Sprintf(":%d", s.props.Port)) + if err != nil { + return err + } + server := &http.Server{Handler: s} + s.httpServer = server + s.boundPort = listener.Addr().(*net.TCPAddr).Port + s.running = true + go func() { + err := server.Serve(listener) + s.mu.Lock() + if s.httpServer == server { + s.running = false + } + s.mu.Unlock() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logy.Get().Error("HTTP server stopped unexpectedly", err) + } + }() + logy.Get().Info("HTTP server started on port {}", s.boundPort) return nil } -// Stop gracefully shuts down the server without interrupting -// any active connections. +// Stop drains active requests; a timeout also closes remaining connections. func (s *Server) Stop(ctx context.Context) error { - return s.httpServer.Shutdown(ctx) + s.mu.Lock() + server := s.httpServer + if server == nil { + s.mu.Unlock() + return nil + } + s.stopping = true + s.mu.Unlock() + err := server.Shutdown(ctx) + if err != nil { + err = errors.Join(err, server.Close()) + } + s.mu.Lock() + s.running = false + s.stopping = false + s.mu.Unlock() + return err } -// Port returns the port number the server is configured to listen on. func (s *Server) Port() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.boundPort != 0 { + return s.boundPort + } return s.props.Port } @@ -119,5 +160,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.contextPool.Put(ctx) }() - _ = s.dispatcher.Dispatch(ctx) + if err := s.dispatcher.Dispatch(ctx); err != nil { + logy.Get().Error("HTTP request failed", err) + if !ctx.Response().IsCommitted() { + _ = ctx.Response().Reset() + ctx.Response().SetStatus(StatusInternalServerError) + } + } else if ctx.Endpoint() == nil && !ctx.Response().IsCommitted() && ctx.Response().Status() == StatusOK { + ctx.Response().SetStatus(StatusNotFound) + } + ctx.Response().writeHeaders() +} + +// serverLifecycle keeps lifecycle discovery separate from runtime.Server. +type serverLifecycle struct{ server *Server } + +func newServerLifecycle(server *Server) *serverLifecycle { + return &serverLifecycle{server: server} +} + +func (s *serverLifecycle) Start(ctx context.Context) error { return s.server.Start(ctx) } +func (s *serverLifecycle) Stop(ctx context.Context) error { return s.server.Stop(ctx) } +func (s *serverLifecycle) IsRunning() bool { + s.server.mu.RLock() + defer s.server.mu.RUnlock() + return s.server.running } diff --git a/lifecycle.go b/lifecycle.go index 5d342de..212547f 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -16,6 +16,7 @@ package procyon import ( "context" + "errors" "fmt" "reflect" "sync" @@ -41,6 +42,7 @@ type defaultLifecycleManager struct { func newDefaultLifecycleManager(container component.Container) *defaultLifecycleManager { return &defaultLifecycleManager{ container: container, + shutdownTimeout: 30 * time.Second, lifecycleObjects: make(map[string]runtime.Lifecycle), } } @@ -65,10 +67,18 @@ func (d *defaultLifecycleManager) Startup(ctx context.Context) error { d.lifecycleObjects[definition.Name()] = lifecycleObj.(runtime.Lifecycle) } + started := make([]runtime.Lifecycle, 0, len(d.lifecycleObjects)) for objectName, lifecycle := range d.lifecycleObjects { if err := lifecycle.Start(ctx); err != nil { - return fmt.Errorf("start lifecycle component %q: %w", objectName, err) + startupErr := fmt.Errorf("start lifecycle component %q: %w", objectName, err) + shutdownCtx, cancel := context.WithTimeout(context.Background(), d.shutdownTimeout) + defer cancel() + for i := len(started) - 1; i >= 0; i-- { + startupErr = errors.Join(startupErr, started[i].Stop(shutdownCtx)) + } + return startupErr } + started = append(started, lifecycle) log.Debug("Started lifecycle component '{}'", objectName) } diff --git a/lifecycle_test.go b/lifecycle_test.go index 94ed7a0..6d11aca 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -20,11 +20,11 @@ import ( "testing" "time" - "codnect.io/procyon/component" "codnect.io/procyon/runtime" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "gocodnect.io/procyon/component" ) func TestDefaultLifecycleManager_Startup(t *testing.T) { diff --git a/test/main.go b/test/main.go new file mode 100644 index 0000000..b8c4085 --- /dev/null +++ b/test/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + + "codnect.io/procyon" + "codnect.io/procyon/component" + "codnect.io/procyon/http" +) + +type UserResponse struct { + Name string `json:"name"` +} + +type WelcomeController struct { +} + +func NewWelcomeController() *WelcomeController { + return &WelcomeController{} +} + +func (w *WelcomeController) ConfigureEndpoints(endpoints http.Endpoints) { + endpoints.MapGet("/hello", http.HandleResult(w.hello)) +} + +func (w *WelcomeController) hello(ctx *http.Context) (http.Result, error) { + return http.Json(UserResponse{ + Name: "John Doe", + }, http.StatusOK), nil +} + +func main() { + component.Register(NewWelcomeController) + http.ServerProperties{} + if err := procyon.New().Run(); err != nil { + os.Exit(1) + } +} diff --git a/web/context.go b/web/context.go new file mode 100644 index 0000000..ebf8e6e --- /dev/null +++ b/web/context.go @@ -0,0 +1,11 @@ +package web + +import "codnect.io/procyon/http" + +type Context struct { + *http.Context +} + +type ModelContext[T any] struct { + *http.EndpointContext[T] +} diff --git a/web/result.go b/web/result.go new file mode 100644 index 0000000..79a9d5f --- /dev/null +++ b/web/result.go @@ -0,0 +1,18 @@ +package web + +import ( + "codnect.io/procyon/http" +) + +type ViewResultExecutor struct { +} + +func (v *ViewResultExecutor) CanExecute(result http.Result) bool { + _, ok := result.(ViewResult) + return ok +} + +func (v *ViewResultExecutor) Execute(ctx *http.Context, result http.Result) error { + //TODO implement me + panic("implement me") +} diff --git a/web/view.go b/web/view.go new file mode 100644 index 0000000..621d81a --- /dev/null +++ b/web/view.go @@ -0,0 +1,42 @@ +package web + +import "codnect.io/procyon/http" + +type ViewResult struct { + ViewName string + Model any + StatusCode http.Status +} + +func View(name string) ViewResult { + return ViewResult{ + ViewName: name, + StatusCode: http.StatusOK, + } +} + +func ViewModel(name string, model any) ViewResult { + return ViewResult{ + ViewName: name, + Model: model, + StatusCode: http.StatusOK, + } +} + +func ViewStatus(name string, status http.Status) ViewResult { + return ViewResult{ + ViewName: name, + StatusCode: status, + } +} + +func (v ViewResult) Status() http.Status { + return v.StatusCode +} + +func (v ViewResult) Header() http.Header { + return nil +} + +type ViewResolver interface { +}