diff --git a/.gitignore b/.gitignore index 56bebff..ebf56d9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ # Test binary, built with `go test -c` *.test bin -**/cmd services/**/gen api-cli @@ -24,3 +23,4 @@ api-cli # Go workspace file go.work +.idea diff --git a/scripts/build b/build similarity index 100% rename from scripts/build rename to build diff --git a/scripts/clean b/clean similarity index 71% rename from scripts/clean rename to clean index 3bab387..69bc229 100755 --- a/scripts/clean +++ b/clean @@ -11,7 +11,7 @@ echo "Rebuilding services..." mkdir -p bin for svc in front character item inventory; do - rm -rf services/${svc}/gen services/${svc}/cmd services/${svc}/gen services/${svc}/*.go + rm -rf services/${svc}/gen done popd \ No newline at end of file diff --git a/scripts/example b/example similarity index 100% rename from scripts/example rename to example diff --git a/scripts/gen b/gen similarity index 100% rename from scripts/gen rename to gen diff --git a/scripts/server b/server similarity index 100% rename from scripts/server rename to server diff --git a/services/character/cmd/character-cli/grpc.go b/services/character/cmd/character-cli/grpc.go new file mode 100644 index 0000000..2b6edda --- /dev/null +++ b/services/character/cmd/character-cli/grpc.go @@ -0,0 +1,27 @@ +package main + +import ( + cli "crossnokaye-interview-assignment/services/character/gen/grpc/cli/character" + "fmt" + "os" + + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + conn, err := grpc.Dial(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + } + return cli.ParseEndpoint(conn) +} + +func grpcUsageCommands() string { + return cli.UsageCommands() +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/services/character/cmd/character-cli/main.go b/services/character/cmd/character-cli/main.go new file mode 100644 index 0000000..220338d --- /dev/null +++ b/services/character/cmd/character-cli/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8083" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + var ( + endpoint goa.Endpoint + payload any + err error + ) + { + switch scheme { + case "grpc", "grpcs": + endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + + data, err := endpoint(context.Background(), payload) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + if data != nil { + m, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(m)) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s is a command line client for the character API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(grpcUsageCommands()), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.Replace(s, "\n", "\n ", -1) +} diff --git a/services/character/cmd/character/grpc.go b/services/character/cmd/character/grpc.go new file mode 100644 index 0000000..fc2564f --- /dev/null +++ b/services/character/cmd/character/grpc.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + character "crossnokaye-interview-assignment/services/character/gen/character" + characterpb "crossnokaye-interview-assignment/services/character/gen/grpc/character/pb" + charactersvr "crossnokaye-interview-assignment/services/character/gen/grpc/character/server" + "log" + "net" + "net/url" + "sync" + + grpcmdlwr "goa.design/goa/v3/grpc/middleware" + "goa.design/goa/v3/middleware" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, characterEndpoints *character.Endpoints, wg *sync.WaitGroup, errc chan error, logger *log.Logger, debug bool) { + + // Setup goa log adapter. + var ( + adapter middleware.Logger + ) + { + adapter = middleware.NewLogger(logger) + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + characterServer *charactersvr.Server + ) + { + characterServer = charactersvr.New(characterEndpoints, nil) + } + + // Initialize gRPC server with the middleware. + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor( + grpcmdlwr.UnaryRequestID(), + grpcmdlwr.UnaryServerLog(adapter), + ), + ) + + // Register the servers. + characterpb.RegisterCharacterServer(srv, characterServer) + + for svc, info := range srv.GetServiceInfo() { + for _, m := range info.Methods { + logger.Printf("serving gRPC method %s", svc+"/"+m.Name) + } + } + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + logger.Printf("gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + logger.Printf("shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} diff --git a/services/character/cmd/character/main.go b/services/character/cmd/character/main.go new file mode 100644 index 0000000..c2506e5 --- /dev/null +++ b/services/character/cmd/character/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + characterapi "crossnokaye-interview-assignment/services/character" + character "crossnokaye-interview-assignment/services/character/gen/character" + "flag" + "fmt" + "log" + "net" + "net/url" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + // Define command line flags, add any other flag required to configure the + // service. + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + grpcPortF = flag.String("grpc-port", "", "gRPC port (overrides host gRPC port specified in service design)") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") + ) + flag.Parse() + + // Setup logger. Replace logger with your own log package of choice. + var ( + logger *log.Logger + ) + { + logger = log.New(os.Stderr, "[characterapi] ", log.Ltime) + } + + // Initialize the services. + var ( + characterSvc character.Service + ) + { + characterSvc = characterapi.NewCharacter(logger) + } + + // Wrap the services in endpoints that can be invoked from other services + // potentially running in different processes. + var ( + characterEndpoints *character.Endpoints + ) + { + characterEndpoints = character.NewEndpoints(characterSvc) + } + + // Create channel used by both the signal handler and server goroutines + // to notify the main goroutine when to stop the server. + errc := make(chan error) + + // Setup interrupt handler. This optional step configures the process so + // that SIGINT and SIGTERM signals cause the services to stop gracefully. + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + errc <- fmt.Errorf("%s", <-c) + }() + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + + // Start the servers and send errors (if any) to the error channel. + switch *hostF { + case "localhost": + { + addr := "grpc://localhost:8083" + u, err := url.Parse(addr) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", addr, err) + } + if *secureF { + u.Scheme = "grpcs" + } + if *domainF != "" { + u.Host = *domainF + } + if *grpcPortF != "" { + h, _, err := net.SplitHostPort(u.Host) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", u.Host, err) + } + u.Host = net.JoinHostPort(h, *grpcPortF) + } else if u.Port() == "" { + u.Host = net.JoinHostPort(u.Host, "8080") + } + handleGRPCServer(ctx, u, characterEndpoints, &wg, errc, logger, *dbgF) + } + + default: + logger.Fatalf("invalid host argument: %q (valid hosts: localhost)\n", *hostF) + } + + // Wait for signal. + logger.Printf("exiting (%v)", <-errc) + + // Send cancellation signal to the goroutines. + cancel() + + wg.Wait() + logger.Println("exited") +} diff --git a/services/front/client/item/itemClient.go b/services/front/client/item/itemClient.go index 5b94507..b54268c 100644 --- a/services/front/client/item/itemClient.go +++ b/services/front/client/item/itemClient.go @@ -1,4 +1,4 @@ -package itemClient +package item import ( "context" @@ -38,11 +38,11 @@ func New(clientConnection *grpc.ClientConn) ItemClient { // createItemRequest implements ItemClient. func (itemClient *itemClient) CreateItemRequest(ctx context.Context, item genItem.Item) (*genItem.Item, error) { - res, err := itemClient.createItem(ctx, item); + res, err := itemClient.createItem(ctx, item) if err != nil { return nil, err } - + return res.(*genItem.Item), nil } diff --git a/services/front/cmd/front-cli/grpc.go b/services/front/cmd/front-cli/grpc.go new file mode 100644 index 0000000..67513bf --- /dev/null +++ b/services/front/cmd/front-cli/grpc.go @@ -0,0 +1,19 @@ +package main + +import ( + cli "crossnokaye-interview-assignment/services/front/gen/grpc/cli/front" + "fmt" + "os" + + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + conn, err := grpc.Dial(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + } + return cli.ParseEndpoint(conn) +} diff --git a/services/front/cmd/front-cli/http.go b/services/front/cmd/front-cli/http.go new file mode 100644 index 0000000..6918314 --- /dev/null +++ b/services/front/cmd/front-cli/http.go @@ -0,0 +1,39 @@ +package main + +import ( + cli "crossnokaye-interview-assignment/services/front/gen/http/cli/front" + "net/http" + "time" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + return cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + ) +} + +func httpUsageCommands() string { + return cli.UsageCommands() +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/services/front/cmd/front-cli/main.go b/services/front/cmd/front-cli/main.go new file mode 100644 index 0000000..351db94 --- /dev/null +++ b/services/front/cmd/front-cli/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:8000" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + var ( + endpoint goa.Endpoint + payload any + err error + ) + { + switch scheme { + case "http", "https": + endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + case "grpc", "grpcs": + endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + + data, err := endpoint(context.Background(), payload) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + if data != nil { + m, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(m)) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s is a command line client for the front API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(httpUsageCommands()), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.Replace(s, "\n", "\n ", -1) +} diff --git a/services/front/cmd/front/http.go b/services/front/cmd/front/http.go new file mode 100644 index 0000000..7522950 --- /dev/null +++ b/services/front/cmd/front/http.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + front "crossnokaye-interview-assignment/services/front/gen/front" + frontsvr "crossnokaye-interview-assignment/services/front/gen/http/front/server" + "log" + "net/http" + "net/url" + "os" + "sync" + "time" + + goahttp "goa.design/goa/v3/http" + httpmdlwr "goa.design/goa/v3/http/middleware" + "goa.design/goa/v3/middleware" +) + +// handleHTTPServer starts configures and starts a HTTP server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleHTTPServer(ctx context.Context, u *url.URL, frontEndpoints *front.Endpoints, wg *sync.WaitGroup, errc chan error, logger *log.Logger, debug bool) { + + // Setup goa log adapter. + var ( + adapter middleware.Logger + ) + { + adapter = middleware.NewLogger(logger) + } + + // Provide the transport specific request decoder and response encoder. + // The goa http package has built-in support for JSON, XML and gob. + // Other encodings can be used by providing the corresponding functions, + // see goa.design/implement/encoding. + var ( + dec = goahttp.RequestDecoder + enc = goahttp.ResponseEncoder + ) + + // Build the service HTTP request multiplexer and configure it to serve + // HTTP requests to the service endpoints. + var mux goahttp.Muxer + { + mux = goahttp.NewMuxer() + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to HTTP requests and + // responses. + var ( + frontServer *frontsvr.Server + ) + { + eh := errorHandler(logger) + frontServer = frontsvr.New(frontEndpoints, mux, dec, enc, eh, nil, nil) + if debug { + servers := goahttp.Servers{ + frontServer, + } + servers.Use(httpmdlwr.Debug(mux, os.Stdout)) + } + } + // Configure the mux. + frontsvr.Mount(mux, frontServer) + + // Wrap the multiplexer with additional middlewares. Middlewares mounted + // here apply to all the service endpoints. + var handler http.Handler = mux + { + handler = httpmdlwr.Log(adapter)(handler) + handler = httpmdlwr.RequestID()(handler) + } + + // Start HTTP server using default configuration, change the code to + // configure the server as required by your service. + srv := &http.Server{Addr: u.Host, Handler: handler, ReadHeaderTimeout: time.Second * 60} + for _, m := range frontServer.Mounts { + logger.Printf("HTTP %q mounted on %s %s", m.Method, m.Verb, m.Pattern) + } + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start HTTP server in a separate goroutine. + go func() { + logger.Printf("HTTP server listening on %q", u.Host) + errc <- srv.ListenAndServe() + }() + + <-ctx.Done() + logger.Printf("shutting down HTTP server at %q", u.Host) + + // Shutdown gracefully with a 30s timeout. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := srv.Shutdown(ctx) + if err != nil { + logger.Printf("failed to shutdown: %v", err) + } + }() +} + +// errorHandler returns a function that writes and logs the given error. +// The function also writes and logs the error unique ID so that it's possible +// to correlate. +func errorHandler(logger *log.Logger) func(context.Context, http.ResponseWriter, error) { + return func(ctx context.Context, w http.ResponseWriter, err error) { + id := ctx.Value(middleware.RequestIDKey).(string) + _, _ = w.Write([]byte("[" + id + "] encoding: " + err.Error())) + logger.Printf("[%s] ERROR: %s", id, err.Error()) + } +} diff --git a/services/front/cmd/front/main.go b/services/front/cmd/front/main.go new file mode 100644 index 0000000..edf66ce --- /dev/null +++ b/services/front/cmd/front/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + frontapi "crossnokaye-interview-assignment/services/front" + front "crossnokaye-interview-assignment/services/front/gen/front" + "flag" + "fmt" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "log" + "net" + "net/url" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + // Define command line flags, add any other flag required to configure the + // service. + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") + itemAddr = flag.String("locator-addr", ":8082", "Item service address") + //characterAddr = flag.String("locator-addr", ":8080", "Item service address") + //inventoryAddr = flag.String("locator-addr", ":8081", "Item service address") + ) + flag.Parse() + + // Setup logger. Replace logger with your own log package of choice. + var ( + logger *log.Logger + ) + { + logger = log.New(os.Stderr, "[frontapi] ", log.Ltime) + } + + // Init gRPC services + itemClientConnection, err := grpc.Dial(*itemAddr, + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + log.Fatalf("failed to connect to item service", err) + os.Exit(1) + } + defer itemClientConnection.Close() + + // Initialize the services. + var ( + frontSvc front.Service + ) + { + frontSvc = frontapi.NewFront(logger, itemClientConnection) + } + + // Wrap the services in endpoints that can be invoked from other services + // potentially running in different processes. + var ( + frontEndpoints *front.Endpoints + ) + { + frontEndpoints = front.NewEndpoints(frontSvc) + } + + // Create channel used by both the signal handler and server goroutines + // to notify the main goroutine when to stop the server. + errc := make(chan error) + + // Setup interrupt handler. This optional step configures the process so + // that SIGINT and SIGTERM signals cause the services to stop gracefully. + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + errc <- fmt.Errorf("%s", <-c) + }() + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + + // Start the servers and send errors (if any) to the error channel. + switch *hostF { + case "localhost": + { + addr := "http://localhost:8000" + u, err := url.Parse(addr) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", addr, err) + } + if *secureF { + u.Scheme = "https" + } + if *domainF != "" { + u.Host = *domainF + } + if *httpPortF != "" { + h, _, err := net.SplitHostPort(u.Host) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", u.Host, err) + } + u.Host = net.JoinHostPort(h, *httpPortF) + } else if u.Port() == "" { + u.Host = net.JoinHostPort(u.Host, "80") + } + handleHTTPServer(ctx, u, frontEndpoints, &wg, errc, logger, *dbgF) + } + + default: + logger.Fatalf("invalid host argument: %q (valid hosts: localhost)\n", *hostF) + } + + // Wait for signal. + logger.Printf("exiting (%v)", <-errc) + + // Send cancellation signal to the goroutines. + cancel() + + wg.Wait() + logger.Println("exited") +} diff --git a/services/front/design/design.go b/services/front/design/design.go index cdd0f24..4920bec 100644 --- a/services/front/design/design.go +++ b/services/front/design/design.go @@ -9,11 +9,11 @@ import ( var _ = API("front", func() { Title("API Front Service") Description("An HTTP/JSON front service which provides an API to manipulate the Characters, their inventories, and the Items that exist") - Server("front", func() { - Host("localhost", func() { - URI("http://localhost:8000") - }) - }) + Server("front", func() { + Host("localhost", func() { + URI("http://localhost:8000") + }) + }) }) var _ = Service("front", func() { @@ -30,10 +30,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - }) }) // Method("listItems", func() { @@ -50,10 +46,6 @@ var _ = Service("front", func() { Response(StatusOK) Response(StatusBadRequest) }) - - GRPC(func() { - Response(CodeOK) - }) }) Method("updateItem", func() { @@ -69,10 +61,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - }) }) Method("deleteItem", func() { @@ -87,12 +75,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) Method("getCharacter", func() { @@ -107,12 +89,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) // Method("listCharacters", func() { @@ -131,12 +107,6 @@ var _ = Service("front", func() { Response(StatusOK) Response(StatusInternalServerError) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) Method("updateCharacter", func() { @@ -152,12 +122,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) Method("deleteCharacter", func() { @@ -172,12 +136,6 @@ var _ = Service("front", func() { Response(StatusBadRequest) Response(StatusNotFound) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) Method("addItemToInventory", func() { @@ -191,10 +149,6 @@ var _ = Service("front", func() { Response(StatusOK) Response(StatusBadRequest) }) - - GRPC(func() { - Response(CodeOK) - }) }) Method("removeItemFromInventory", func() { @@ -208,12 +162,6 @@ var _ = Service("front", func() { Response(StatusOK) Response(StatusBadRequest) }) - - GRPC(func() { - Response(CodeOK) - Response("NotFound", CodeNotFound) - Response("BadRequest", CodeInvalidArgument) - }) }) Files("/openapi.json", "./gen/http/openapi.json") diff --git a/services/front/front.go b/services/front/front.go index e9c18f3..fdc8e71 100644 --- a/services/front/front.go +++ b/services/front/front.go @@ -3,6 +3,8 @@ package frontapi import ( "context" front "crossnokaye-interview-assignment/services/front/gen/front" + genClient "crossnokaye-interview-assignment/services/item/gen/grpc/item/client" + "google.golang.org/grpc" "log" ) @@ -10,17 +12,20 @@ import ( // The example methods log the requests and return zero values. type frontsrvc struct { logger *log.Logger + + itemClient *genClient.Client } // NewFront returns the front service implementation. -func NewFront(logger *log.Logger) front.Service { - return &frontsrvc{logger} +func NewFront(logger *log.Logger, itemClientConnection *grpc.ClientConn) front.Service { + + return &frontsrvc{logger: logger, itemClient: genClient.NewClient(itemClientConnection)} } // GetItem implements getItem. -func (s *frontsrvc) GetItem(ctx context.Context, p int) (res *front.Item, err error) { - res = &front.Item{} +func (s *frontsrvc) GetItem(ctx context.Context, id int) (res *front.Item, err error) { s.logger.Print("front.getItem") + s.logger.Print(s.itemClient.GetItem()) return } diff --git a/services/inventory/cmd/inventory-cli/grpc.go b/services/inventory/cmd/inventory-cli/grpc.go new file mode 100644 index 0000000..bfeb4f4 --- /dev/null +++ b/services/inventory/cmd/inventory-cli/grpc.go @@ -0,0 +1,27 @@ +package main + +import ( + cli "crossnokaye-interview-assignment/services/inventory/gen/grpc/cli/inventory" + "fmt" + "os" + + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + conn, err := grpc.Dial(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + } + return cli.ParseEndpoint(conn) +} + +func grpcUsageCommands() string { + return cli.UsageCommands() +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/services/inventory/cmd/inventory-cli/main.go b/services/inventory/cmd/inventory-cli/main.go new file mode 100644 index 0000000..14f3793 --- /dev/null +++ b/services/inventory/cmd/inventory-cli/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8081" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + var ( + endpoint goa.Endpoint + payload any + err error + ) + { + switch scheme { + case "grpc", "grpcs": + endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + + data, err := endpoint(context.Background(), payload) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + if data != nil { + m, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(m)) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s is a command line client for the inventory API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(grpcUsageCommands()), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.Replace(s, "\n", "\n ", -1) +} diff --git a/services/inventory/cmd/inventory/grpc.go b/services/inventory/cmd/inventory/grpc.go new file mode 100644 index 0000000..7a4a549 --- /dev/null +++ b/services/inventory/cmd/inventory/grpc.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + inventorypb "crossnokaye-interview-assignment/services/inventory/gen/grpc/inventory/pb" + inventorysvr "crossnokaye-interview-assignment/services/inventory/gen/grpc/inventory/server" + inventory "crossnokaye-interview-assignment/services/inventory/gen/inventory" + "log" + "net" + "net/url" + "sync" + + grpcmdlwr "goa.design/goa/v3/grpc/middleware" + "goa.design/goa/v3/middleware" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, inventoryEndpoints *inventory.Endpoints, wg *sync.WaitGroup, errc chan error, logger *log.Logger, debug bool) { + + // Setup goa log adapter. + var ( + adapter middleware.Logger + ) + { + adapter = middleware.NewLogger(logger) + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + inventoryServer *inventorysvr.Server + ) + { + inventoryServer = inventorysvr.New(inventoryEndpoints, nil) + } + + // Initialize gRPC server with the middleware. + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor( + grpcmdlwr.UnaryRequestID(), + grpcmdlwr.UnaryServerLog(adapter), + ), + ) + + // Register the servers. + inventorypb.RegisterInventoryServer(srv, inventoryServer) + + for svc, info := range srv.GetServiceInfo() { + for _, m := range info.Methods { + logger.Printf("serving gRPC method %s", svc+"/"+m.Name) + } + } + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + logger.Printf("gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + logger.Printf("shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} diff --git a/services/inventory/cmd/inventory/main.go b/services/inventory/cmd/inventory/main.go new file mode 100644 index 0000000..4a762e4 --- /dev/null +++ b/services/inventory/cmd/inventory/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + inventoryapi "crossnokaye-interview-assignment/services/inventory" + inventory "crossnokaye-interview-assignment/services/inventory/gen/inventory" + "flag" + "fmt" + "log" + "net" + "net/url" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + // Define command line flags, add any other flag required to configure the + // service. + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + grpcPortF = flag.String("grpc-port", "", "gRPC port (overrides host gRPC port specified in service design)") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") + ) + flag.Parse() + + // Setup logger. Replace logger with your own log package of choice. + var ( + logger *log.Logger + ) + { + logger = log.New(os.Stderr, "[inventoryapi] ", log.Ltime) + } + + // Initialize the services. + var ( + inventorySvc inventory.Service + ) + { + inventorySvc = inventoryapi.NewInventory(logger) + } + + // Wrap the services in endpoints that can be invoked from other services + // potentially running in different processes. + var ( + inventoryEndpoints *inventory.Endpoints + ) + { + inventoryEndpoints = inventory.NewEndpoints(inventorySvc) + } + + // Create channel used by both the signal handler and server goroutines + // to notify the main goroutine when to stop the server. + errc := make(chan error) + + // Setup interrupt handler. This optional step configures the process so + // that SIGINT and SIGTERM signals cause the services to stop gracefully. + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + errc <- fmt.Errorf("%s", <-c) + }() + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + + // Start the servers and send errors (if any) to the error channel. + switch *hostF { + case "localhost": + { + addr := "grpc://localhost:8081" + u, err := url.Parse(addr) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", addr, err) + } + if *secureF { + u.Scheme = "grpcs" + } + if *domainF != "" { + u.Host = *domainF + } + if *grpcPortF != "" { + h, _, err := net.SplitHostPort(u.Host) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", u.Host, err) + } + u.Host = net.JoinHostPort(h, *grpcPortF) + } else if u.Port() == "" { + u.Host = net.JoinHostPort(u.Host, "8080") + } + handleGRPCServer(ctx, u, inventoryEndpoints, &wg, errc, logger, *dbgF) + } + + default: + logger.Fatalf("invalid host argument: %q (valid hosts: localhost)\n", *hostF) + } + + // Wait for signal. + logger.Printf("exiting (%v)", <-errc) + + // Send cancellation signal to the goroutines. + cancel() + + wg.Wait() + logger.Println("exited") +} diff --git a/services/item/cmd/item-cli/grpc.go b/services/item/cmd/item-cli/grpc.go new file mode 100644 index 0000000..c099782 --- /dev/null +++ b/services/item/cmd/item-cli/grpc.go @@ -0,0 +1,27 @@ +package main + +import ( + cli "crossnokaye-interview-assignment/services/item/gen/grpc/cli/item" + "fmt" + "os" + + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { + conn, err := grpc.Dial(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + } + return cli.ParseEndpoint(conn) +} + +func grpcUsageCommands() string { + return cli.UsageCommands() +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/services/item/cmd/item-cli/main.go b/services/item/cmd/item-cli/main.go new file mode 100644 index 0000000..1928596 --- /dev/null +++ b/services/item/cmd/item-cli/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8082" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + var ( + endpoint goa.Endpoint + payload any + err error + ) + { + switch scheme { + case "grpc", "grpcs": + endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if err == flag.ErrHelp { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + + data, err := endpoint(context.Background(), payload) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + if data != nil { + m, _ := json.MarshalIndent(data, "", " ") + fmt.Println(string(m)) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `%s is a command line client for the item API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(grpcUsageCommands()), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.Replace(s, "\n", "\n ", -1) +} diff --git a/services/item/cmd/item/grpc.go b/services/item/cmd/item/grpc.go new file mode 100644 index 0000000..6204f3b --- /dev/null +++ b/services/item/cmd/item/grpc.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + itempb "crossnokaye-interview-assignment/services/item/gen/grpc/item/pb" + itemsvr "crossnokaye-interview-assignment/services/item/gen/grpc/item/server" + item "crossnokaye-interview-assignment/services/item/gen/item" + "log" + "net" + "net/url" + "sync" + + grpcmdlwr "goa.design/goa/v3/grpc/middleware" + "goa.design/goa/v3/middleware" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, itemEndpoints *item.Endpoints, wg *sync.WaitGroup, errc chan error, logger *log.Logger, debug bool) { + + // Setup goa log adapter. + var ( + adapter middleware.Logger + ) + { + adapter = middleware.NewLogger(logger) + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + itemServer *itemsvr.Server + ) + { + itemServer = itemsvr.New(itemEndpoints, nil) + } + + // Initialize gRPC server with the middleware. + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor( + grpcmdlwr.UnaryRequestID(), + grpcmdlwr.UnaryServerLog(adapter), + ), + ) + + // Register the servers. + itempb.RegisterItemServer(srv, itemServer) + + for svc, info := range srv.GetServiceInfo() { + for _, m := range info.Methods { + logger.Printf("serving gRPC method %s", svc+"/"+m.Name) + } + } + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + logger.Printf("gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + logger.Printf("shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} diff --git a/services/item/cmd/item/main.go b/services/item/cmd/item/main.go new file mode 100644 index 0000000..8ab662e --- /dev/null +++ b/services/item/cmd/item/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + itemapi "crossnokaye-interview-assignment/services/item" + item "crossnokaye-interview-assignment/services/item/gen/item" + "flag" + "fmt" + "log" + "net" + "net/url" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + // Define command line flags, add any other flag required to configure the + // service. + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + grpcPortF = flag.String("grpc-port", "", "gRPC port (overrides host gRPC port specified in service design)") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") + ) + flag.Parse() + + // Setup logger. Replace logger with your own log package of choice. + var ( + logger *log.Logger + ) + { + logger = log.New(os.Stderr, "[itemapi] ", log.Ltime) + } + + // Initialize the services. + var ( + itemSvc item.Service + ) + { + itemSvc = itemapi.NewItem(logger) + } + + // Wrap the services in endpoints that can be invoked from other services + // potentially running in different processes. + var ( + itemEndpoints *item.Endpoints + ) + { + itemEndpoints = item.NewEndpoints(itemSvc) + } + + // Create channel used by both the signal handler and server goroutines + // to notify the main goroutine when to stop the server. + errc := make(chan error) + + // Setup interrupt handler. This optional step configures the process so + // that SIGINT and SIGTERM signals cause the services to stop gracefully. + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + errc <- fmt.Errorf("%s", <-c) + }() + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + + // Start the servers and send errors (if any) to the error channel. + switch *hostF { + case "localhost": + { + addr := "grpc://localhost:8082" + u, err := url.Parse(addr) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", addr, err) + } + if *secureF { + u.Scheme = "grpcs" + } + if *domainF != "" { + u.Host = *domainF + } + if *grpcPortF != "" { + h, _, err := net.SplitHostPort(u.Host) + if err != nil { + logger.Fatalf("invalid URL %#v: %s\n", u.Host, err) + } + u.Host = net.JoinHostPort(h, *grpcPortF) + } else if u.Port() == "" { + u.Host = net.JoinHostPort(u.Host, "8080") + } + handleGRPCServer(ctx, u, itemEndpoints, &wg, errc, logger, *dbgF) + } + + default: + logger.Fatalf("invalid host argument: %q (valid hosts: localhost)\n", *hostF) + } + + // Wait for signal. + logger.Printf("exiting (%v)", <-errc) + + // Send cancellation signal to the goroutines. + cancel() + + wg.Wait() + logger.Println("exited") +}