diff --git a/CHANGELOG.md b/CHANGELOG.md index 981730313..ca1299695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - feat: Reduce logger output during development [#576](https://github.com/hypermodeinc/modus/pull/576) - chore: Trigger internal release pipeline at the end of the release-runtime workflow [#577](https://github.com/hypermodeinc/modus/pull/577) +- feat: Add API explorer to runtime [#578](https://github.com/hypermodeinc/modus/pull/578) ## 2024-11-08 - CLI 0.13.8 diff --git a/runtime/explorer/content/index.html b/runtime/explorer/content/index.html new file mode 100644 index 000000000..4565f5843 --- /dev/null +++ b/runtime/explorer/content/index.html @@ -0,0 +1,69 @@ + + + + + + Modus API Explorer + + +

Modus API Explorer

+

+ This is the Modus API Explorer. You can use this tool to explore the API + and test the endpoints. +

+

Note, this works, but will soon be replaced by something much nicer!

+ +

Available Endpoints

+ + +

GraphQL Query

+ + + + +

Response

+ + + + + diff --git a/runtime/explorer/explorer.go b/runtime/explorer/explorer.go new file mode 100644 index 000000000..597f67d9d --- /dev/null +++ b/runtime/explorer/explorer.go @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Hypermode Inc. + * Licensed under the terms of the Apache License, Version 2.0 + * See the LICENSE file that accompanied this code for further details. + * + * SPDX-FileCopyrightText: 2024 Hypermode Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package explorer + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/hypermodeinc/modus/lib/manifest" + "github.com/hypermodeinc/modus/runtime/manifestdata" + "github.com/hypermodeinc/modus/runtime/utils" +) + +//go:embed content +var content embed.FS +var contentRoot, _ = fs.Sub(content, "content") + +var ExplorerHandler = http.HandlerFunc(explorerHandler) + +func explorerHandler(w http.ResponseWriter, r *http.Request) { + + mux := http.NewServeMux() + mux.Handle("/explorer/", http.StripPrefix("/explorer/", http.FileServerFS(contentRoot))) + mux.HandleFunc("/explorer/api/endpoints", endpointsHandler) + + mux.ServeHTTP(w, r) +} + +func endpointsHandler(w http.ResponseWriter, r *http.Request) { + + type endpoint struct { + ApiType string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + } + + m := manifestdata.GetManifest() + + endpoints := make([]endpoint, 0, len(m.Endpoints)) + for name, ep := range m.Endpoints { + switch ep.EndpointType() { + case manifest.EndpointTypeGraphQL: + info := ep.(manifest.GraphqlEndpointInfo) + endpoints = append(endpoints, endpoint{"GraphQL", name, info.Path}) + } + } + + utils.WriteJsonContentHeader(w) + j, _ := utils.JsonSerialize(endpoints) + _, _ = w.Write(j) +} diff --git a/runtime/graphql/graphql.go b/runtime/graphql/graphql.go index 98ae2aae6..4d34b0092 100644 --- a/runtime/graphql/graphql.go +++ b/runtime/graphql/graphql.go @@ -58,6 +58,15 @@ func Initialize() { } func handleGraphQLRequest(w http.ResponseWriter, r *http.Request) { + + // In dev, redirect non-GraphQL requests to the explorer + if config.IsDevEnvironment() && + r.Method == http.MethodGet && + !strings.Contains(r.Header.Get("Accept"), "application/json") { + http.Redirect(w, r, "/explorer", http.StatusTemporaryRedirect) + return + } + ctx := r.Context() // Read the incoming GraphQL request diff --git a/runtime/httpserver/dynamicMux.go b/runtime/httpserver/dynamicMux.go index 5bd07df1a..a844759e8 100644 --- a/runtime/httpserver/dynamicMux.go +++ b/runtime/httpserver/dynamicMux.go @@ -11,6 +11,7 @@ package httpserver import ( "net/http" + "strings" "sync" ) @@ -33,9 +34,26 @@ func (dm *dynamicMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handler, ok := dm.routes[r.URL.Path]; ok { handler.ServeHTTP(w, r) - } else { - http.Error(w, "Endpoint not found", http.StatusNotFound) + return } + + path := strings.TrimSuffix(r.URL.Path, "/") + if path != r.URL.Path { + if _, ok := dm.routes[path]; ok { + http.Redirect(w, r, path, http.StatusMovedPermanently) + return + } + } + + for route, handler := range dm.routes { + if len(route) > 1 && strings.HasSuffix(route, "/") && + (strings.HasPrefix(r.URL.Path, route) || route == r.URL.Path+"/") { + handler.ServeHTTP(w, r) + return + } + } + + http.Error(w, "Not Found", http.StatusNotFound) } func (dm *dynamicMux) ReplaceRoutes(routes map[string]http.Handler) { diff --git a/runtime/httpserver/server.go b/runtime/httpserver/server.go index 9b605bc77..e2343dd93 100644 --- a/runtime/httpserver/server.go +++ b/runtime/httpserver/server.go @@ -23,6 +23,7 @@ import ( "github.com/hypermodeinc/modus/lib/manifest" "github.com/hypermodeinc/modus/runtime/app" "github.com/hypermodeinc/modus/runtime/config" + "github.com/hypermodeinc/modus/runtime/explorer" "github.com/hypermodeinc/modus/runtime/graphql" "github.com/hypermodeinc/modus/runtime/logger" "github.com/hypermodeinc/modus/runtime/manifestdata" @@ -132,6 +133,12 @@ func GetMainHandler(options ...func(map[string]http.Handler)) http.Handler { "/health": healthHandler, "/metrics": metrics.MetricsHandler, } + + if config.IsDevEnvironment() { + defaultRoutes["/explorer/"] = explorer.ExplorerHandler + defaultRoutes["/"] = http.RedirectHandler("/explorer/", http.StatusSeeOther) + } + for _, opt := range options { opt(defaultRoutes) }