Skip to content
This repository was archived by the owner on Sep 11, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions runtime/explorer/content/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modus API Explorer</title>
</head>
<body>
<h1>Modus API Explorer</h1>
<p>
This is the Modus API Explorer. You can use this tool to explore the API
and test the endpoints.
</p>
<p>Note, this works, but will soon be replaced by something much nicer!</p>

<h2>Available Endpoints</h2>
<select id="endpoint"></select>

<h2>GraphQL Query</h2>
<textarea
id="query"
rows="10"
cols="50"
placeholder="Enter your GraphQL query here..."
></textarea>

<button id="submit">Submit Query</button>

<h2>Response</h2>
<textarea id="response" rows="10" cols="50" readonly></textarea>

<script>
async function populateEndpoints() {
const response = await fetch("/explorer/api/endpoints");
const endpoints = await response.json();
const endpointSelect = document.getElementById("endpoint");
endpoints.forEach((ep) => {
const option = document.createElement("option");
option.value = ep.path;
option.text = `${ep.name}: ${ep.path}`;
endpointSelect.appendChild(option);
});
}

populateEndpoints();

document.getElementById("submit").addEventListener("click", async () => {
const endpoint = document.getElementById("endpoint").value;
const query = document.getElementById("query").value;
const responseTextarea = document.getElementById("response");

try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
});

const result = await response.json();
responseTextarea.value = JSON.stringify(result, null, 2);
} catch (error) {
responseTextarea.value = `Error: ${error.message}`;
}
});
</script>
</body>
</html>
59 changes: 59 additions & 0 deletions runtime/explorer/explorer.go
Original file line number Diff line number Diff line change
@@ -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. <[email protected]>
* 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)
}
9 changes: 9 additions & 0 deletions runtime/graphql/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions runtime/httpserver/dynamicMux.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package httpserver

import (
"net/http"
"strings"
"sync"
)

Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions runtime/httpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down