~repos /gromer

#golang#htmx#ssr

git clone https://pyrossh.dev/repos/gromer.git

gromer is a framework and cli to build multipage web apps in golang using htmx and alpinejs.


cbe79923 Peter John

tag: v0.7.0

v0.7.0

4 years ago
Add ApiExplorer
Files changed (4) hide show
  1. api_explorer.go +301 -0
  2. cmd/wapp/main.go +20 -63
  3. css.go +4 -0
  4. html.go +1 -1
api_explorer.go ADDED
@@ -0,0 +1,301 @@
1
+ package wapp
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ )
8
+
9
+ func Select(uis ...interface{}) *Element {
10
+ return NewElement("select", false, uis...)
11
+ }
12
+
13
+ func Option(uis ...interface{}) *Element {
14
+ return NewElement("option", false, uis...)
15
+ }
16
+
17
+ func Table(uis ...interface{}) *Element {
18
+ return NewElement("table", false, uis...)
19
+ }
20
+
21
+ func TR(uis ...interface{}) *Element {
22
+ return NewElement("tr", false, uis...)
23
+ }
24
+
25
+ func TD(uis ...interface{}) *Element {
26
+ return NewElement("td", false, uis...)
27
+ }
28
+
29
+ func Section(title string) *Element {
30
+ return Div(Css("text-gray-700 text-sm font-bold uppercase pl-2 pt-2 pb-2 bg-gray-50 border-b border-gray-200"), Text(title))
31
+ }
32
+
33
+ type ApiDefinition struct {
34
+ Method string `json:"method"`
35
+ Path string `json:"path"`
36
+ PathParams []string `json:"pathParams"`
37
+ QueryParams map[string]string `json:"queryParams"`
38
+ }
39
+
40
+ func ApiExplorer(apiDefs []ApiDefinition) func(c context.Context) (HtmlPage, int, error) {
41
+ return func(c context.Context) (HtmlPage, int, error) {
42
+ data, err := json.Marshal(apiDefs)
43
+ if err != nil {
44
+ return Html(nil, nil), 500, err
45
+ }
46
+ options := []interface{}{ID("api-select"), Css("form-select block")}
47
+ for i, c := range apiDefs {
48
+ options = append(options, Option(Attr("value", fmt.Sprintf("%d", i)), Div(Text(fmt.Sprintf("%0.6s %s", c.Method, c.Path)))))
49
+ }
50
+ return Html(
51
+ Head(
52
+ Title("Example"),
53
+ Meta("description", "Example"),
54
+ Meta("author", "pyros2097"),
55
+ Meta("keywords", "wapp,pyros2097"),
56
+ Meta("viewport", "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, viewport-fit=cover"),
57
+ Link("icon", "/assets/icon.png"),
58
+ Link("stylesheet", "https://cdn.jsdelivr.net/npm/codemirror@5.63.1/lib/codemirror.css"),
59
+ StyleTag(Text(`
60
+ html, body {
61
+ height: 100vh;
62
+ overflow: hidden;
63
+ }
64
+
65
+ .CodeMirror {
66
+ height: 100vh;
67
+ }
68
+
69
+ .form-select {
70
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23a0aec0'%3e%3cpath d='M15.3 9.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4l3.3 3.29 3.3-3.3z'/%3e%3c/svg%3e");
71
+ -webkit-appearance: none;
72
+ -moz-appearance: none;
73
+ appearance: none;
74
+ -webkit-print-color-adjust: exact;
75
+ color-adjust: exact;
76
+ background-repeat: no-repeat;
77
+ background-color: #fff;
78
+ border-color: #e2e8f0;
79
+ border-width: 1px;
80
+ border-radius: 0.25rem;
81
+ padding-top: 0.5rem;
82
+ padding-right: 2.5rem;
83
+ padding-bottom: 0.5rem;
84
+ padding-left: 0.75rem;
85
+ font-size: 1rem;
86
+ line-height: 1.5;
87
+ background-position: right 0.5rem center;
88
+ background-size: 1.5em 1.5em;
89
+ }
90
+
91
+ .form-select:focus {
92
+ outline: none;
93
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
94
+ border-color: #63b3ed;
95
+ }
96
+
97
+ td, th {
98
+ // border-bottom: 1px solid rgb(204, 204, 204);
99
+ border-left: 1px solid rgb(204, 204, 204);
100
+ text-align: left;
101
+ }
102
+
103
+ textarea:focus, input:focus{
104
+ outline: none;
105
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
106
+ border-color: #63b3ed;
107
+ }
108
+
109
+ *:focus {
110
+ outline: none;
111
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
112
+ border-color: #63b3ed;
113
+ }
114
+
115
+ .spinner {
116
+ animation: rotate 2s linear infinite;
117
+ width: 24px;
118
+ height: 24px;
119
+ }
120
+
121
+ .spinner .path {
122
+ stroke: rgba(249, 250, 251, 1);
123
+ stroke-linecap: round;
124
+ animation: dash 1.5s ease-in-out infinite;
125
+ }
126
+
127
+ @keyframes rotate {
128
+ 100% {
129
+ transform: rotate(360deg);
130
+ }
131
+ }
132
+
133
+ @keyframes dash {
134
+ 0% {
135
+ stroke-dasharray: 1, 150;
136
+ stroke-dashoffset: 0;
137
+ }
138
+ 50% {
139
+ stroke-dasharray: 90, 150;
140
+ stroke-dashoffset: -35;
141
+ }
142
+ 100% {
143
+ stroke-dasharray: 90, 150;
144
+ stroke-dashoffset: -124;
145
+ }
146
+ }
147
+ `)),
148
+ Script(Src("https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.1/codemirror.min.js")),
149
+ Script(Src("https://cdn.jsdelivr.net/npm/codemirror@5.63.1/mode/javascript/javascript.js")),
150
+ ),
151
+ Body(
152
+ Div(Css("flex flex-col w-screen h-screen"),
153
+ Div(Css("flex w-full p-2 bg-gray-50 border-b border-gray-200 items-center justify-start"),
154
+ Div(Css("flex mr-4 text-gray-700 text-2xl font-bold"), Text("API Explorer")),
155
+ Div(Css("text-xl"),
156
+ Select(options...),
157
+ ),
158
+ Div(Css("flex ml-3 mr-3"), Button(ID("run"), Css("bg-gray-200 border border-gray-400 hover:bg-gray-200 focus:outline-none rounded-md text-gray-700 text-md font-bold pt-2 pb-2 pl-6 pr-6"),
159
+ Text("RUN"),
160
+ )),
161
+ ),
162
+ Div(Css("flex w-full h-full"),
163
+ Div(Css("h-full"), Style("width: 50%;"),
164
+ Section("Headers"),
165
+ Table(ID("headersTable"), Css("w-full"),
166
+ TR(
167
+ TD(Style("width: 50%;"), Input(Css("w-full p-1"), Attr("value", "Authorization"))),
168
+ TD(Style("width: 50%;"), Input(Css("w-full p-1"))),
169
+ ),
170
+ ),
171
+ Section("Path Params"),
172
+ Table(ID("pathParamsTable"), Css("w-full"),
173
+ TR(
174
+ TD(Css("text-gray-700"), Style("width: 50%;"), Div(Css("p-1"), Text("123"))),
175
+ TD(Style("width: 50%;"), Input(Css("w-full p-1"))),
176
+ ),
177
+ ),
178
+ Section("Query Params"),
179
+ Table(ID("queryParamsTable"), Css("w-full"),
180
+ TR(
181
+ TD(Css("text-gray-700"), Style("width: 50%;"), Div(Css("p-1"), Text("123"))),
182
+ TD(Style("width: 50%;"), Input(Css("w-full p-1"))),
183
+ ),
184
+ ),
185
+ Section("Body"),
186
+ Div(ID("left"), Style("height: 400px; overflow: scroll;"), Css("border-b border-gray-200 text-lg")),
187
+ ),
188
+ Div(ID("right"), Css("h-full border-l border-l-gray-200 text-lg"), Style("width: 50%;")),
189
+ ),
190
+ ),
191
+ Script(Text("window.apiDefs = "+string(data))),
192
+ Script(Text("window.codeLeft = CodeMirror(document.getElementById('left'), {value: '{}',mode: 'javascript', lineNumbers: true })")),
193
+ Script(Text("window.codeRight = CodeMirror(document.getElementById('right'), {value: '',mode: 'javascript', lineNumbers: true, readOnly: true, lineWrapping: true })")),
194
+ Script(Text(`
195
+ const getCurrentApiCall = () => {
196
+ const index = document.getElementById("api-select").value;
197
+ return window.apiDefs[index];
198
+ }
199
+
200
+ const updatePathParams = (apiCall) => {
201
+ const table = document.getElementById("pathParamsTable");
202
+ if (apiCall.pathParams.length === 0) {
203
+ table.innerHTML = "<div style='background-color: rgb(245, 245, 245); padding: 0.25rem; text-align: center; color: gray;'>NONE</div>";
204
+ } else {
205
+ table.innerHTML = "";
206
+ }
207
+ for(const param of apiCall.pathParams.reverse()) {
208
+ const row = table.insertRow(0);
209
+ const cell1 = row.insertCell(0);
210
+ const cell2 = row.insertCell(1);
211
+ cell1.style = "width: 50%;";
212
+ cell1.class = "text-gray-700";
213
+ cell1.innerHTML = "<div class='p-1'>" + param + "</div>";
214
+ cell2.innerHTML = "<input id='path-param-" + param + "' class='w-full p-1'>";
215
+ }
216
+ }
217
+
218
+ const updateQueryParams = (apiCall) => {
219
+ const table = document.getElementById("queryParamsTable");
220
+ if (!apiCall.queryParams) {
221
+ table.innerHTML = "<div style='background-color: rgb(245, 245, 245); padding: 0.25rem; text-align: center; color: gray;'>NONE</div>";
222
+ } else {
223
+ table.innerHTML = "";
224
+ }
225
+ }
226
+
227
+ const updateBody = (apiCall) => {
228
+ const editor = document.getElementById("left");
229
+ }
230
+
231
+ const init = () => {
232
+ updatePathParams(window.apiDefs[0]);
233
+ updateQueryParams(window.apiDefs[0]);
234
+ const headersJson = localStorage.getItem("headers");
235
+ if (headersJson) {
236
+ const table = document.getElementById("headersTable");
237
+ const headers = JSON.parse(headersJson);
238
+ table.innerHTML = "";
239
+ for(const key of Object.keys(headers)) {
240
+ const value = headers[key];
241
+ const row = table.insertRow(0);
242
+ const cell1 = row.insertCell(0);
243
+ const cell2 = row.insertCell(1);
244
+ cell1.style = "width: 50%;";
245
+ cell2.style = "width: 50%;";
246
+ cell1.innerHTML = "<input value='" + key + "' class='w-full p-1'>";
247
+ cell2.innerHTML = "<input value='" + value + "' class='w-full p-1'>";
248
+ }
249
+ }
250
+ }
251
+
252
+ window.onload = () => {
253
+ init();
254
+ }
255
+
256
+ document.getElementById("api-select").onchange = () => {
257
+ const apiCall = getCurrentApiCall();
258
+ updatePathParams(apiCall);
259
+ updateQueryParams(apiCall);
260
+ updateBody(apiCall);
261
+ }
262
+
263
+ const run = document.getElementById("run");
264
+ run.onclick = async () => {
265
+ run.innerHTML = "<svg class='spinner' viewBox='0 0 50 50'><circle class='path' cx='25' cy='25' r='20' fill='none' stroke-width='5'></circle></svg>";
266
+ const table = document.getElementById("headersTable");
267
+ const headers = {};
268
+ for(const row of table.rows) {
269
+ const key = row.cells[0].children[0].value;
270
+ const value = row.cells[1].children[0].value;
271
+ headers[key] = value;
272
+ }
273
+ const apiCall = getCurrentApiCall();
274
+ let path = apiCall.path;
275
+ const bodyParams = {};
276
+ if (apiCall.method !== "GET" && apiCall.method != "DELETE") {
277
+ bodyParams["body"] = window.codeLeft.getValue();
278
+ }
279
+ for(const param of apiCall.pathParams) {
280
+ const value = document.getElementById('path-param-' + param).value;
281
+ path = path.replace('{' + param + '}', value);
282
+ }
283
+ localStorage.setItem("headers", JSON.stringify(headers));
284
+ try {
285
+ const res = await fetch(path, {
286
+ method: apiCall.method,
287
+ headers,
288
+ ...bodyParams
289
+ });
290
+ const json = await res.json();
291
+ window.codeRight.setValue(JSON.stringify(json, 2, 2));
292
+ } catch (err) {
293
+ window.codeRight.setValue(JSON.stringify({ error: err.message }, 2, 2));
294
+ }
295
+ run.innerHTML = "RUN";
296
+ }
297
+ `)),
298
+ ),
299
+ ), 200, nil
300
+ }
301
+ }
cmd/wapp/main.go CHANGED
@@ -20,13 +20,6 @@ type Route struct {
20
20
  Pkg string
21
21
  }
22
22
 
23
- type ApiCall struct {
24
- Method string
25
- Name string
26
- Params string
27
- Path string
28
- }
29
-
30
23
  func getMethod(src string) string {
31
24
  if strings.HasSuffix(src, "get.go") {
32
25
  return "GET"
@@ -85,20 +78,13 @@ func rewritePkg(pkg string) string {
85
78
  return lastItem
86
79
  }
87
80
 
88
- func getApiFunc(method, route string, params []string) ApiCall {
81
+ func getApiFunc(method, route string, params []string) wapp.ApiDefinition {
89
82
  muxRoute := bytes.NewBuffer(nil)
90
83
  foundStart := false
91
- funcName := strings.ToLower(method)
92
- parts := strings.Split(route, "/")
93
- for _, p := range parts {
94
- if p != "api" {
95
- funcName += strings.Title(strings.Replace(p, "_", "", 2))
96
- }
97
- }
98
84
  for _, v := range route {
99
85
  if string(v) == "_" && !foundStart {
100
86
  foundStart = true
101
- muxRoute.WriteString("${")
87
+ muxRoute.WriteString("{")
102
88
  } else if string(v) == "_" && foundStart {
103
89
  foundStart = false
104
90
  muxRoute.WriteString("}")
@@ -106,15 +92,10 @@ func getApiFunc(method, route string, params []string) ApiCall {
106
92
  muxRoute.WriteString(string(v))
107
93
  }
108
94
  }
109
- paramsStrings := ""
110
- if len(params) > 0 {
111
- paramsStrings += strings.Join(params, ", ") + ", params"
112
- }
113
- return ApiCall{
95
+ return wapp.ApiDefinition{
114
- Method: method,
96
+ Method: method,
115
- Name: funcName,
116
- Params: paramsStrings,
97
+ PathParams: params,
117
- Path: muxRoute.String(),
98
+ Path: muxRoute.String(),
118
99
  }
119
100
  }
120
101
 
@@ -183,7 +164,7 @@ func main() {
183
164
  }
184
165
  moduleName := modTree.Module.Mod.Path
185
166
  routes := []*Route{}
186
- apiCalls := []ApiCall{}
167
+ apiDefs := []wapp.ApiDefinition{}
187
168
  allPkgs := map[string]string{}
188
169
  err = filepath.Walk("pages",
189
170
  func(filesrc string, info os.FileInfo, err error) error {
@@ -208,7 +189,7 @@ func main() {
208
189
  Pkg: rewritePkg(pkg),
209
190
  })
210
191
  if strings.Contains(path, "/api/") {
211
- apiCalls = append(apiCalls, getApiFunc(method, path, params))
192
+ apiDefs = append(apiDefs, getApiFunc(method, path, params))
212
193
  }
213
194
  }
214
195
  return nil
@@ -223,7 +204,7 @@ func main() {
223
204
  ctx.Set("moduleName", moduleName)
224
205
  ctx.Set("allPkgs", allPkgs)
225
206
  ctx.Set("routes", routes)
226
- ctx.Set("apiCalls", apiCalls)
207
+ ctx.Set("apiDefs", apiDefs)
227
208
  ctx.Set("tick", "`")
228
209
  s, err := velvet.Render(`// Code generated by wapp. DO NOT EDIT.
229
210
  package main
@@ -253,6 +234,7 @@ func main() {
253
234
  r := mux.NewRouter()
254
235
  r.NotFoundHandler = http.HandlerFunc(notFound)
255
236
  r.PathPrefix("/assets/").Handler(http.FileServer(http.FS(assetsFS)))
237
+ handle(r, "GET", "/api", wapp.ApiExplorer(apiDefinitions()))
256
238
  {{#each routes as |route| }}handle(r, "{{ route.Method }}", "{{ route.Path }}", {{ route.Pkg }}.{{ route.Method }})
257
239
  {{/each}}
258
240
  if !isLambda {
@@ -293,47 +275,22 @@ func handle(router *mux.Router, method, route string, h interface{}) {
293
275
  }
294
276
  }).Methods(method)
295
277
  }
296
- `, ctx)
297
- if err != nil {
298
- panic(err)
299
- }
300
- err = ioutil.WriteFile("main.go", []byte(s), 0644)
301
- if err != nil {
302
- panic(err)
303
- }
304
- js, err := velvet.Render(`// Code generated by wapp. DO NOT EDIT.
305
- import queryString from 'query-string';
306
- import config from './config';
307
-
308
- const apiCall = async (method, route, params) => {
309
- const qs = method === 'GET' && params ? '?' + queryString.stringify(params) : '';
310
- const body = method !== 'GET' ? JSON.stringify(params) : null;
311
- const endpoint = await config.getApiEndpoint();
312
- const token = await config.getAuthToken();
313
- const res = await fetch({{tick}}${endpoint}/api/${route}${qs}{{tick}}, {
314
- method,
315
- headers: {
316
- Authorization: token,
317
- },
318
- body,
319
- });
320
- const json = await res.json();
321
- if (res.ok) {
322
- return json;
323
- } else {
324
- throw new Error(json.error);
325
- }
326
- }
327
278
 
279
+ func apiDefinitions() []wapp.ApiDefinition {
328
- export default {
280
+ return []wapp.ApiDefinition{
281
+ {{#each apiDefs as |api| }}
282
+ {
283
+ Method: "{{api.Method}}",
284
+ Path: "{{api.Path}}",
329
- {{#each apiCalls as |api| }}{{api.Name}}: ({{api.Params}}) => apiCall('{{api.Method}}', {{tick}}{{api.Path}}{{tick}}, params),
285
+ PathParams: []string{ {{#each api.PathParams as |param| }}"{{param}}", {{/each}} },
330
- {{/each}}
286
+ },{{/each}}
287
+ }
331
288
  }
332
289
  `, ctx)
333
290
  if err != nil {
334
291
  panic(err)
335
292
  }
336
- err = ioutil.WriteFile("api.js", []byte(js), 0644)
293
+ err = ioutil.WriteFile("main.go", []byte(s), 0644)
337
294
  if err != nil {
338
295
  panic(err)
339
296
  }
css.go CHANGED
@@ -344,6 +344,10 @@ var twClassLookup = map[string]string{
344
344
  "justify-between": "justify-content: space-between;",
345
345
  "justify-around": "justify-content: space-around;",
346
346
  "justify-evenly": "justify-content: space-evenly;",
347
+ "uppercase": "text-transform: uppercase",
348
+ "lowercase": "text-transform: lowercase",
349
+ "capitalize": "text-transform: capitalize",
350
+ "normal-case": "text-transform: normal-case",
347
351
  "text-left": "text-align: left;",
348
352
  "text-center": "text-align: center;",
349
353
  "text-right": "text-align: right;",
html.go CHANGED
@@ -290,7 +290,7 @@ func Span(uis ...interface{}) *Element {
290
290
  }
291
291
 
292
292
  func Input(uis ...interface{}) *Element {
293
- return NewElement("input", false, uis...)
293
+ return NewElement("input", true, uis...)
294
294
  }
295
295
 
296
296
  func Image(uis ...interface{}) *Element {