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)
}