initial commit
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/restxlsx/restxlsx/internal/excel"
|
||||
"github.com/restxlsx/restxlsx/internal/logger"
|
||||
)
|
||||
|
||||
type DDLHandler struct {
|
||||
engine *excel.Engine
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func NewDDLHandler(engine *excel.Engine, log *logger.Logger) *DDLHandler {
|
||||
return &DDLHandler{engine: engine, log: log}
|
||||
}
|
||||
|
||||
type createSheetReq struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *DDLHandler) CreateSheet(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSheetReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
if err := h.engine.CreateSheet(req.Name); err != nil {
|
||||
writeJSON(w, 409, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, map[string]any{"sheet": req.Name, "status": "created"})
|
||||
}
|
||||
|
||||
func (h *DDLHandler) DropSheet(w http.ResponseWriter, r *http.Request) {
|
||||
sheet := chi.URLParam(r, "sheet")
|
||||
if sheet == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "sheet name required"})
|
||||
return
|
||||
}
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
if err := h.engine.DropSheet(sheet); err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"sheet": sheet, "status": "deleted"})
|
||||
}
|
||||
|
||||
type createTableReq struct {
|
||||
Sheet string `json:"sheet"`
|
||||
Name string `json:"name"`
|
||||
Columns []string `json:"columns"`
|
||||
}
|
||||
|
||||
func (h *DDLHandler) CreateTable(w http.ResponseWriter, r *http.Request) {
|
||||
var req createTableReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Sheet == "" || len(req.Columns) == 0 {
|
||||
writeJSON(w, 400, map[string]string{"error": "sheet, name, and columns are required"})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
if h.engine.GetTable(req.Sheet) == nil {
|
||||
if err := h.engine.CreateSheet(req.Sheet); err != nil {
|
||||
writeJSON(w, 409, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.engine.CreateTable(req.Sheet, req.Columns, []map[string]any{}); err != nil {
|
||||
writeJSON(w, 409, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t := h.engine.GetTable(req.Sheet)
|
||||
t.Name = req.Name
|
||||
h.engine.Tables[req.Name] = t
|
||||
delete(h.engine.Tables, req.Sheet)
|
||||
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, map[string]any{"table": req.Name, "sheet": req.Sheet, "columns": req.Columns, "status": "created"})
|
||||
}
|
||||
|
||||
func (h *DDLHandler) DropTable(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
if table == "" {
|
||||
writeJSON(w, 400, map[string]string{"error": "table name required"})
|
||||
return
|
||||
}
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
if err := h.engine.DropTable(table); err != nil {
|
||||
writeJSON(w, 404, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"table": table, "status": "deleted"})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func JSONContentType(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/restxlsx/restxlsx/internal/excel"
|
||||
)
|
||||
|
||||
//go:embed swagger-ui.html
|
||||
var swaggerHTML string
|
||||
|
||||
type OpenAPI struct {
|
||||
engine *excel.Engine
|
||||
debug bool
|
||||
}
|
||||
|
||||
func NewOpenAPI(engine *excel.Engine, debug bool) *OpenAPI {
|
||||
return &OpenAPI{engine: engine, debug: debug}
|
||||
}
|
||||
|
||||
func (o *OpenAPI) Spec() map[string]any {
|
||||
spec := map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]any{
|
||||
"title": "restxlsx",
|
||||
"description": "RESTful API over Excel tables. Supports REST CRUD, GraphQL, SQL, and DDL.",
|
||||
"version": "0.2.0",
|
||||
},
|
||||
"paths": o.buildPaths(),
|
||||
}
|
||||
|
||||
if o.debug {
|
||||
spec["servers"] = []map[string]any{
|
||||
{"url": "http://localhost:3000", "description": "Local dev"},
|
||||
}
|
||||
}
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
func (o *OpenAPI) buildPaths() map[string]any {
|
||||
paths := make(map[string]any)
|
||||
|
||||
paths["/api/tables"] = map[string]any{
|
||||
"get": map[string]any{
|
||||
"summary": "List all tables",
|
||||
"operationId": "listTables",
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "List of table names",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"tables": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, name := range o.engine.ListTables() {
|
||||
t := o.engine.GetTable(name)
|
||||
tablePath := fmt.Sprintf("/api/%s", name)
|
||||
itemPath := fmt.Sprintf("/api/%s/{id}", name)
|
||||
|
||||
schema := tableSchema(t)
|
||||
|
||||
paths[tablePath] = map[string]any{
|
||||
"get": map[string]any{
|
||||
"summary": fmt.Sprintf("List all %s", name),
|
||||
"operationId": fmt.Sprintf("list%s", name),
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": fmt.Sprintf("Array of %s", name),
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "array",
|
||||
"items": schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post": map[string]any{
|
||||
"summary": fmt.Sprintf("Create a new %s", name),
|
||||
"operationId": fmt.Sprintf("create%s", name),
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"201": map[string]any{
|
||||
"description": "Created",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
paths[itemPath] = map[string]any{
|
||||
"get": map[string]any{
|
||||
"summary": fmt.Sprintf("Get %s by ID", name),
|
||||
"operationId": fmt.Sprintf("get%s", name),
|
||||
"parameters": []map[string]any{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": fmt.Sprintf("A single %s", name),
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": map[string]any{"description": "Not found"},
|
||||
},
|
||||
},
|
||||
"put": map[string]any{
|
||||
"summary": fmt.Sprintf("Update %s by ID", name),
|
||||
"operationId": fmt.Sprintf("update%s", name),
|
||||
"parameters": []map[string]any{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "Updated"},
|
||||
"404": map[string]any{"description": "Not found"},
|
||||
},
|
||||
},
|
||||
"delete": map[string]any{
|
||||
"summary": fmt.Sprintf("Delete %s by ID", name),
|
||||
"operationId": fmt.Sprintf("delete%s", name),
|
||||
"parameters": []map[string]any{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"204": map[string]any{"description": "Deleted"},
|
||||
"404": map[string]any{"description": "Not found"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
paths["/api/sql"] = map[string]any{
|
||||
"post": map[string]any{
|
||||
"summary": "Execute a SQL query",
|
||||
"operationId": "execSQL",
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "Query result"},
|
||||
"400": map[string]any{"description": "Bad request"},
|
||||
"403": map[string]any{"description": "Forbidden"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
paths["/api/ddl/sheets"] = map[string]any{
|
||||
"post": map[string]any{
|
||||
"summary": "Create a new sheet",
|
||||
"operationId": "createSheet",
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"201": map[string]any{"description": "Created"},
|
||||
"403": map[string]any{"description": "Forbidden"},
|
||||
},
|
||||
},
|
||||
}
|
||||
paths["/api/ddl/sheets/{sheet}"] = map[string]any{
|
||||
"delete": map[string]any{
|
||||
"summary": "Delete a sheet",
|
||||
"operationId": "deleteSheet",
|
||||
"parameters": []map[string]any{
|
||||
{"name": "sheet", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "Deleted"},
|
||||
"403": map[string]any{"description": "Forbidden"},
|
||||
"404": map[string]any{"description": "Not found"},
|
||||
},
|
||||
},
|
||||
}
|
||||
paths["/api/ddl/tables"] = map[string]any{
|
||||
"post": map[string]any{
|
||||
"summary": "Create a table on a sheet",
|
||||
"operationId": "createTable",
|
||||
"requestBody": map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"sheet": map[string]any{"type": "string"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
"columns": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"201": map[string]any{"description": "Created"},
|
||||
"403": map[string]any{"description": "Forbidden"},
|
||||
},
|
||||
},
|
||||
}
|
||||
paths["/api/ddl/tables/{table}"] = map[string]any{
|
||||
"delete": map[string]any{
|
||||
"summary": "Drop a table",
|
||||
"operationId": "deleteTable",
|
||||
"parameters": []map[string]any{
|
||||
{"name": "table", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "Deleted"},
|
||||
"403": map[string]any{"description": "Forbidden"},
|
||||
"404": map[string]any{"description": "Not found"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func tableSchema(t *excel.Table) map[string]any {
|
||||
props := make(map[string]any)
|
||||
required := make([]string, 0)
|
||||
|
||||
for _, col := range t.Columns {
|
||||
props[col] = map[string]any{"type": "string"}
|
||||
required = append(required, col)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": required,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OpenAPI) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
spec := o.Spec()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(spec)
|
||||
}
|
||||
|
||||
func (o *OpenAPI) SwaggerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
specBytes, _ := json.Marshal(o.Spec())
|
||||
specStr := strings.ReplaceAll(string(specBytes), `"`, `\"`)
|
||||
html := strings.Replace(swaggerHTML, "{{SPEC}}", specStr, 1)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/restxlsx/restxlsx/internal/excel"
|
||||
"github.com/restxlsx/restxlsx/internal/logger"
|
||||
)
|
||||
|
||||
type RestAPI struct {
|
||||
engine *excel.Engine
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func NewRestAPI(engine *excel.Engine, log *logger.Logger) *RestAPI {
|
||||
return &RestAPI{engine: engine, log: log}
|
||||
}
|
||||
|
||||
func (h *RestAPI) ListTables(w http.ResponseWriter, r *http.Request) {
|
||||
h.engine.Lock()
|
||||
tables := h.engine.ListTables()
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 200, map[string][]string{"tables": tables})
|
||||
}
|
||||
|
||||
func (h *RestAPI) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
h.engine.Lock()
|
||||
t := h.engine.GetTable(table)
|
||||
if t == nil {
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 404, map[string]string{"error": "table not found"})
|
||||
return
|
||||
}
|
||||
rows := t.Rows
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 200, rows)
|
||||
}
|
||||
|
||||
func (h *RestAPI) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
id := chi.URLParam(r, "id")
|
||||
h.engine.Lock()
|
||||
t := h.engine.GetTable(table)
|
||||
if t == nil {
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 404, map[string]string{"error": "table not found"})
|
||||
return
|
||||
}
|
||||
idCol := h.engine.IDColumn(table)
|
||||
for _, row := range t.Rows {
|
||||
if fmt.Sprintf("%v", row[idCol]) == id {
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 200, row)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.engine.Unlock()
|
||||
writeJSON(w, 404, map[string]string{"error": "record not found"})
|
||||
}
|
||||
|
||||
func (h *RestAPI) CreateRecord(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
t := h.engine.GetTable(table)
|
||||
if t == nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "table not found"})
|
||||
return
|
||||
}
|
||||
|
||||
idCol := h.engine.IDColumn(table)
|
||||
if idCol != "" {
|
||||
if _, ok := rec[idCol]; !ok {
|
||||
rec[idCol] = len(t.Rows) + 1
|
||||
}
|
||||
}
|
||||
|
||||
t.Rows = append(t.Rows, rec)
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, rec)
|
||||
}
|
||||
|
||||
func (h *RestAPI) UpdateRecord(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
t := h.engine.GetTable(table)
|
||||
if t == nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "table not found"})
|
||||
return
|
||||
}
|
||||
|
||||
idCol := h.engine.IDColumn(table)
|
||||
for i, row := range t.Rows {
|
||||
if fmt.Sprintf("%v", row[idCol]) == id {
|
||||
for k, v := range rec {
|
||||
t.Rows[i][k] = v
|
||||
}
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, t.Rows[i])
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 404, map[string]string{"error": "record not found"})
|
||||
}
|
||||
|
||||
func (h *RestAPI) DeleteRecord(w http.ResponseWriter, r *http.Request) {
|
||||
table := chi.URLParam(r, "table")
|
||||
id := chi.URLParam(r, "id")
|
||||
h.engine.Lock()
|
||||
defer h.engine.Unlock()
|
||||
t := h.engine.GetTable(table)
|
||||
if t == nil {
|
||||
writeJSON(w, 404, map[string]string{"error": "table not found"})
|
||||
return
|
||||
}
|
||||
|
||||
idCol := h.engine.IDColumn(table)
|
||||
for i, row := range t.Rows {
|
||||
if fmt.Sprintf("%v", row[idCol]) == id {
|
||||
t.Rows = append(t.Rows[:i], t.Rows[i+1:]...)
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
writeJSON(w, 503, map[string]string{"error": "flush failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 204, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 404, map[string]string{"error": "record not found"})
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if data != nil {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/restxlsx/restxlsx/internal/auth"
|
||||
"github.com/restxlsx/restxlsx/internal/excel"
|
||||
"github.com/restxlsx/restxlsx/internal/logger"
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokenKind
|
||||
value string
|
||||
}
|
||||
|
||||
type tokenKind int
|
||||
|
||||
const (
|
||||
tokEOF tokenKind = iota
|
||||
tokIdent
|
||||
tokString
|
||||
tokNumber
|
||||
tokStar
|
||||
tokComma
|
||||
tokLParen
|
||||
tokRParen
|
||||
tokEq
|
||||
tokSemicolon
|
||||
tokKeyword
|
||||
)
|
||||
|
||||
type sqlStmt struct {
|
||||
kind string // SELECT, INSERT, UPDATE, DELETE
|
||||
table string
|
||||
columns []string
|
||||
values []string
|
||||
sets map[string]string
|
||||
whereCol string
|
||||
whereVal string
|
||||
}
|
||||
|
||||
type SQLHandler struct {
|
||||
engine *excel.Engine
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func NewSQLHandler(engine *excel.Engine, log *logger.Logger) *SQLHandler {
|
||||
return &SQLHandler{engine: engine, log: log}
|
||||
}
|
||||
|
||||
func (h *SQLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
stmt, err := parseSQL(body.Query)
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": "parse error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
role := auth.RoleFromContext(r.Context())
|
||||
switch stmt.kind {
|
||||
case "SELECT":
|
||||
case "INSERT", "UPDATE", "DELETE":
|
||||
if !role.CanWrite() {
|
||||
writeJSON(w, 403, map[string]string{"error": "forbidden: write access required"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeJSON(w, 400, map[string]string{"error": "unsupported statement: " + stmt.kind})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
result, err := h.execute(stmt)
|
||||
h.engine.Unlock()
|
||||
if err != nil {
|
||||
writeJSON(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (h *SQLHandler) execute(stmt sqlStmt) (any, error) {
|
||||
table := h.engine.GetTable(stmt.table)
|
||||
if table == nil {
|
||||
return nil, fmt.Errorf("table %q not found", stmt.table)
|
||||
}
|
||||
|
||||
switch stmt.kind {
|
||||
case "SELECT":
|
||||
return h.executeSelect(table, stmt)
|
||||
case "INSERT":
|
||||
return h.executeInsert(table, stmt)
|
||||
case "UPDATE":
|
||||
return h.executeUpdate(table, stmt)
|
||||
case "DELETE":
|
||||
return h.executeDelete(table, stmt)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported: %s", stmt.kind)
|
||||
}
|
||||
|
||||
func (h *SQLHandler) executeSelect(table *excel.Table, stmt sqlStmt) (any, error) {
|
||||
var results []map[string]any
|
||||
for _, row := range table.Rows {
|
||||
if stmt.whereCol != "" {
|
||||
if fmt.Sprintf("%v", row[stmt.whereCol]) != stmt.whereVal {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(stmt.columns) == 1 && stmt.columns[0] == "*" {
|
||||
results = append(results, row)
|
||||
} else {
|
||||
projected := make(map[string]any)
|
||||
for _, col := range stmt.columns {
|
||||
if v, ok := row[col]; ok {
|
||||
projected[col] = v
|
||||
}
|
||||
}
|
||||
results = append(results, projected)
|
||||
}
|
||||
}
|
||||
if stmt.whereCol != "" && len(results) == 1 {
|
||||
return results[0], nil
|
||||
}
|
||||
if results == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (h *SQLHandler) executeInsert(table *excel.Table, stmt sqlStmt) (any, error) {
|
||||
if len(stmt.columns) != len(stmt.values) {
|
||||
return nil, fmt.Errorf("column count (%d) != value count (%d)", len(stmt.columns), len(stmt.values))
|
||||
}
|
||||
rec := make(map[string]any)
|
||||
for i, col := range stmt.columns {
|
||||
rec[col] = parseValue(stmt.values[i])
|
||||
}
|
||||
idCol := h.engine.IDColumn(table.Name)
|
||||
if idCol != "" {
|
||||
if _, ok := rec[idCol]; !ok {
|
||||
rec[idCol] = len(table.Rows) + 1
|
||||
}
|
||||
}
|
||||
table.Rows = append(table.Rows, rec)
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (h *SQLHandler) executeUpdate(table *excel.Table, stmt sqlStmt) (any, error) {
|
||||
var updated []map[string]any
|
||||
for i, row := range table.Rows {
|
||||
if stmt.whereCol != "" {
|
||||
if fmt.Sprintf("%v", row[stmt.whereCol]) != stmt.whereVal {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for k, v := range stmt.sets {
|
||||
table.Rows[i][k] = parseValue(v)
|
||||
}
|
||||
updated = append(updated, table.Rows[i])
|
||||
}
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if updated == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (h *SQLHandler) executeDelete(table *excel.Table, stmt sqlStmt) (any, error) {
|
||||
var deleted []map[string]any
|
||||
kept := make([]map[string]any, 0, len(table.Rows))
|
||||
for _, row := range table.Rows {
|
||||
if stmt.whereCol != "" {
|
||||
if fmt.Sprintf("%v", row[stmt.whereCol]) == stmt.whereVal {
|
||||
deleted = append(deleted, row)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
deleted = append(deleted, row)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, row)
|
||||
}
|
||||
table.Rows = kept
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"deleted": len(deleted)}, nil
|
||||
}
|
||||
|
||||
// ─── SQL Parser ────────────────────────────────────────────────
|
||||
|
||||
type lexer struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
func (l *lexer) peek() byte {
|
||||
if l.pos >= len(l.input) {
|
||||
return 0
|
||||
}
|
||||
return l.input[l.pos]
|
||||
}
|
||||
|
||||
func (l *lexer) advance() byte {
|
||||
b := l.input[l.pos]
|
||||
l.pos++
|
||||
return b
|
||||
}
|
||||
|
||||
func (l *lexer) skipWS() {
|
||||
for l.pos < len(l.input) && (l.input[l.pos] == ' ' || l.input[l.pos] == '\t' || l.input[l.pos] == '\n') {
|
||||
l.pos++
|
||||
}
|
||||
}
|
||||
|
||||
var keywords = map[string]bool{
|
||||
"select": true, "from": true, "where": true,
|
||||
"insert": true, "into": true, "values": true,
|
||||
"update": true, "set": true, "delete": true,
|
||||
}
|
||||
|
||||
func lex(input string) ([]token, error) {
|
||||
l := &lexer{input: input}
|
||||
var toks []token
|
||||
for l.pos < len(l.input) {
|
||||
l.skipWS()
|
||||
if l.pos >= len(l.input) {
|
||||
break
|
||||
}
|
||||
c := l.peek()
|
||||
switch {
|
||||
case c == ',':
|
||||
toks = append(toks, token{tokComma, ","})
|
||||
l.advance()
|
||||
case c == '(':
|
||||
toks = append(toks, token{tokLParen, "("})
|
||||
l.advance()
|
||||
case c == ')':
|
||||
toks = append(toks, token{tokRParen, ")"})
|
||||
l.advance()
|
||||
case c == ';':
|
||||
toks = append(toks, token{tokSemicolon, ";"})
|
||||
l.advance()
|
||||
case c == '=':
|
||||
toks = append(toks, token{tokEq, "="})
|
||||
l.advance()
|
||||
case c == '*':
|
||||
toks = append(toks, token{tokStar, "*"})
|
||||
l.advance()
|
||||
case c == '\'' || c == '"':
|
||||
quote := l.advance()
|
||||
var buf strings.Builder
|
||||
for l.pos < len(l.input) && l.peek() != quote {
|
||||
buf.WriteByte(l.advance())
|
||||
}
|
||||
if l.pos >= len(l.input) {
|
||||
return nil, fmt.Errorf("unterminated string literal")
|
||||
}
|
||||
l.advance()
|
||||
toks = append(toks, token{tokString, buf.String()})
|
||||
case c >= '0' && c <= '9' || c == '-':
|
||||
var buf strings.Builder
|
||||
for l.pos < len(l.input) && (l.peek() >= '0' && l.peek() <= '9' || l.peek() == '.') {
|
||||
buf.WriteByte(l.advance())
|
||||
}
|
||||
toks = append(toks, token{tokNumber, buf.String()})
|
||||
case unicode.IsLetter(rune(c)) || c == '_':
|
||||
var buf strings.Builder
|
||||
for l.pos < len(l.input) && (unicode.IsLetter(rune(l.peek())) || unicode.IsDigit(rune(l.peek())) || l.peek() == '_') {
|
||||
buf.WriteByte(l.advance())
|
||||
}
|
||||
word := buf.String()
|
||||
if keywords[strings.ToLower(word)] {
|
||||
toks = append(toks, token{tokKeyword, strings.ToUpper(word)})
|
||||
} else {
|
||||
toks = append(toks, token{tokIdent, word})
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected character: %c", c)
|
||||
}
|
||||
}
|
||||
if len(toks) == 0 {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
toks = append(toks, token{tokEOF, ""})
|
||||
return toks, nil
|
||||
}
|
||||
|
||||
func parseSQL(input string) (sqlStmt, error) {
|
||||
toks, err := lex(input)
|
||||
if err != nil {
|
||||
return sqlStmt{}, err
|
||||
}
|
||||
pos := 0
|
||||
next := func() token {
|
||||
if pos >= len(toks) {
|
||||
return token{tokEOF, ""}
|
||||
}
|
||||
t := toks[pos]
|
||||
pos++
|
||||
return t
|
||||
}
|
||||
peek := func() token {
|
||||
if pos >= len(toks) {
|
||||
return token{tokEOF, ""}
|
||||
}
|
||||
return toks[pos]
|
||||
}
|
||||
expect := func(kind tokenKind, msg string) (token, error) {
|
||||
t := next()
|
||||
if t.kind != kind {
|
||||
return t, fmt.Errorf("%s: expected %d got %q", msg, kind, t.value)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
first := next()
|
||||
if first.kind != tokKeyword {
|
||||
return sqlStmt{}, fmt.Errorf("expected keyword, got %q", first.value)
|
||||
}
|
||||
|
||||
switch first.value {
|
||||
case "SELECT":
|
||||
return parseSelect(&pos, toks, next, peek, expect)
|
||||
case "INSERT":
|
||||
return parseInsert(&pos, toks, next, peek, expect)
|
||||
case "UPDATE":
|
||||
return parseUpdate(&pos, toks, next, peek, expect)
|
||||
case "DELETE":
|
||||
return parseDelete(&pos, toks, next, peek, expect)
|
||||
default:
|
||||
return sqlStmt{}, fmt.Errorf("unsupported statement: %s", first.value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseSelect(pos *int, toks []token, next, peek func() token, expect func(tokenKind, string) (token, error)) (sqlStmt, error) {
|
||||
stmt := sqlStmt{kind: "SELECT"}
|
||||
|
||||
if peek().kind == tokStar {
|
||||
next()
|
||||
stmt.columns = []string{"*"}
|
||||
} else {
|
||||
for {
|
||||
t, err := expect(tokIdent, "select column")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.columns = append(stmt.columns, t.value)
|
||||
if peek().kind != tokComma {
|
||||
break
|
||||
}
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := expect(tokKeyword, "FROM"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
t, err := expect(tokIdent, "table name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.table = t.value
|
||||
|
||||
if peek().kind == tokKeyword && strings.ToUpper(peek().value) == "WHERE" {
|
||||
next()
|
||||
t, err := expect(tokIdent, "where column")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.whereCol = t.value
|
||||
if _, err := expect(tokEq, "="); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
v := next()
|
||||
if v.kind != tokString && v.kind != tokNumber {
|
||||
return stmt, fmt.Errorf("expected value in WHERE, got %q", v.value)
|
||||
}
|
||||
stmt.whereVal = v.value
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func parseInsert(pos *int, toks []token, next, peek func() token, expect func(tokenKind, string) (token, error)) (sqlStmt, error) {
|
||||
stmt := sqlStmt{kind: "INSERT"}
|
||||
|
||||
if _, err := expect(tokKeyword, "INTO"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
t, err := expect(tokIdent, "table name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.table = t.value
|
||||
|
||||
if _, err := expect(tokLParen, "("); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
for {
|
||||
t, err := expect(tokIdent, "column name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.columns = append(stmt.columns, t.value)
|
||||
if peek().kind != tokComma {
|
||||
break
|
||||
}
|
||||
next()
|
||||
}
|
||||
if _, err := expect(tokRParen, ")"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
|
||||
if _, err := expect(tokKeyword, "VALUES"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
if _, err := expect(tokLParen, "("); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
for {
|
||||
v := next()
|
||||
if v.kind != tokString && v.kind != tokNumber {
|
||||
return stmt, fmt.Errorf("expected value, got %q", v.value)
|
||||
}
|
||||
stmt.values = append(stmt.values, v.value)
|
||||
if peek().kind != tokComma {
|
||||
break
|
||||
}
|
||||
next()
|
||||
}
|
||||
if _, err := expect(tokRParen, ")"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func parseUpdate(pos *int, toks []token, next, peek func() token, expect func(tokenKind, string) (token, error)) (sqlStmt, error) {
|
||||
stmt := sqlStmt{kind: "UPDATE", sets: make(map[string]string)}
|
||||
|
||||
t, err := expect(tokIdent, "table name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.table = t.value
|
||||
|
||||
if _, err := expect(tokKeyword, "SET"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
for {
|
||||
t, err := expect(tokIdent, "column name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
col := t.value
|
||||
if _, err := expect(tokEq, "="); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
v := next()
|
||||
if v.kind != tokString && v.kind != tokNumber {
|
||||
return stmt, fmt.Errorf("expected value in SET, got %q", v.value)
|
||||
}
|
||||
stmt.sets[col] = v.value
|
||||
if peek().kind != tokComma {
|
||||
break
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
if peek().kind == tokKeyword && strings.ToUpper(peek().value) == "WHERE" {
|
||||
next()
|
||||
t, err := expect(tokIdent, "where column")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.whereCol = t.value
|
||||
if _, err := expect(tokEq, "="); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
v := next()
|
||||
if v.kind != tokString && v.kind != tokNumber {
|
||||
return stmt, fmt.Errorf("expected value in WHERE, got %q", v.value)
|
||||
}
|
||||
stmt.whereVal = v.value
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func parseDelete(pos *int, toks []token, next, peek func() token, expect func(tokenKind, string) (token, error)) (sqlStmt, error) {
|
||||
stmt := sqlStmt{kind: "DELETE"}
|
||||
|
||||
if _, err := expect(tokKeyword, "FROM"); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
t, err := expect(tokIdent, "table name")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.table = t.value
|
||||
|
||||
if peek().kind == tokKeyword && strings.ToUpper(peek().value) == "WHERE" {
|
||||
next()
|
||||
t, err := expect(tokIdent, "where column")
|
||||
if err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
stmt.whereCol = t.value
|
||||
if _, err := expect(tokEq, "="); err != nil {
|
||||
return stmt, err
|
||||
}
|
||||
v := next()
|
||||
if v.kind != tokString && v.kind != tokNumber {
|
||||
return stmt, fmt.Errorf("expected value in WHERE, got %q", v.value)
|
||||
}
|
||||
stmt.whereVal = v.value
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func parseValue(s string) any {
|
||||
if i, err := strconv.Atoi(s); err == nil {
|
||||
return i
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>restxlsx - Swagger UI</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
spec: JSON.parse('{{SPEC}}'),
|
||||
dom_id: '#swagger-ui',
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type roleKey struct{}
|
||||
|
||||
type Role int
|
||||
|
||||
const (
|
||||
RoleReader Role = iota
|
||||
RoleWriter
|
||||
RoleAdmin
|
||||
)
|
||||
|
||||
func ParseRole(s string) Role {
|
||||
switch s {
|
||||
case "admin":
|
||||
return RoleAdmin
|
||||
case "writer":
|
||||
return RoleWriter
|
||||
default:
|
||||
return RoleReader
|
||||
}
|
||||
}
|
||||
|
||||
func (r Role) CanRead() bool { return true }
|
||||
func (r Role) CanWrite() bool { return r >= RoleWriter }
|
||||
func (r Role) CanDDL() bool { return r >= RoleAdmin }
|
||||
|
||||
type Handler struct {
|
||||
role Role
|
||||
}
|
||||
|
||||
func New(roleStr string) *Handler {
|
||||
return &Handler{role: ParseRole(roleStr)}
|
||||
}
|
||||
|
||||
func (h *Handler) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), roleKey{}, h.role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func RoleFromContext(ctx context.Context) Role {
|
||||
if r, ok := ctx.Value(roleKey{}).(Role); ok {
|
||||
return r
|
||||
}
|
||||
return RoleReader
|
||||
}
|
||||
|
||||
func RequireWrite(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !RoleFromContext(r.Context()).CanWrite() {
|
||||
http.Error(w, `{"error":"forbidden: write access required"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func RequireDDL(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !RoleFromContext(r.Context()).CanDDL() {
|
||||
http.Error(w, `{"error":"forbidden: admin access required"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func RequireWriteMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !RoleFromContext(r.Context()).CanWrite() {
|
||||
http.Error(w, `{"error":"forbidden: write access required"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
FilePath string
|
||||
Host string
|
||||
Port int
|
||||
Debug bool
|
||||
Quiet bool
|
||||
LogReads bool
|
||||
LogFile string
|
||||
AuthRole string
|
||||
}
|
||||
|
||||
func strEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func intEnv(key string, fallback int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnvOrDefault(key string, fallback bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
FilePath: "data/sample.xlsx",
|
||||
Host: "localhost",
|
||||
Port: 3000,
|
||||
Debug: false,
|
||||
Quiet: false,
|
||||
LogReads: true,
|
||||
AuthRole: "writer",
|
||||
}
|
||||
|
||||
flag.StringVar(&c.FilePath, "file", strEnv("RESTXLSX_FILE", c.FilePath), "Path to xlsx/xlsm file")
|
||||
flag.StringVar(&c.Host, "host", strEnv("RESTXLSX_HOST", c.Host), "Listen host")
|
||||
flag.IntVar(&c.Port, "port", intEnv("RESTXLSX_PORT", c.Port), "HTTP listen port")
|
||||
flag.BoolVar(&c.Debug, "debug", boolEnvOrDefault("RESTXLSX_DEBUG", c.Debug), "Enable debug mode (swagger ui, etc.)")
|
||||
quietFlag := flag.Bool("quiet", false, "Suppress stdout output")
|
||||
flag.BoolVar(&c.LogReads, "log-reads", boolEnvOrDefault("RESTXLSX_LOG_READS", c.LogReads), "Log read operations")
|
||||
flag.StringVar(&c.LogFile, "log-file", strEnv("RESTXLSX_LOG_FILE", c.LogFile), "Optional log file path")
|
||||
flag.StringVar(&c.AuthRole, "auth", strEnv("RESTXLSX_AUTH", c.AuthRole), "Fallback auth role: admin, writer, reader (used when no OIDC token present)")
|
||||
flag.Parse()
|
||||
|
||||
if *quietFlag || strings.EqualFold(os.Getenv("RESTXLSX_CONSOLE"), "quiet") {
|
||||
c.Quiet = true
|
||||
}
|
||||
|
||||
c.AuthRole = strings.ToLower(c.AuthRole)
|
||||
switch c.AuthRole {
|
||||
case "admin", "writer", "reader":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid auth role %q: must be admin, writer, or reader", c.AuthRole)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(c.FilePath), ".xlsx") &&
|
||||
!strings.HasSuffix(strings.ToLower(c.FilePath), ".xlsm") {
|
||||
return nil, fmt.Errorf("unsupported file format: %s (must be .xlsx or .xlsm)", c.FilePath)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package excel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type Table struct {
|
||||
Name string
|
||||
Columns []string
|
||||
Rows []map[string]any
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
mu sync.Mutex
|
||||
filePath string
|
||||
Tables map[string]*Table
|
||||
}
|
||||
|
||||
func (e *Engine) Lock() { e.mu.Lock() }
|
||||
func (e *Engine) Unlock() { e.mu.Unlock() }
|
||||
|
||||
func Open(filePath string) (*Engine, error) {
|
||||
f, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open xlsx: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
e := &Engine{
|
||||
filePath: filePath,
|
||||
Tables: make(map[string]*Table),
|
||||
}
|
||||
|
||||
sheets := f.GetSheetList()
|
||||
for _, sheet := range sheets {
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read sheet %q: %w", sheet, err)
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
t := &Table{
|
||||
Name: sheet,
|
||||
Columns: rows[0],
|
||||
Rows: make([]map[string]any, 0, len(rows)-1),
|
||||
}
|
||||
|
||||
for _, row := range rows[1:] {
|
||||
rec := make(map[string]any, len(t.Columns))
|
||||
for i, col := range t.Columns {
|
||||
if i < len(row) {
|
||||
rec[col] = inferValue(row[i])
|
||||
} else {
|
||||
rec[col] = nil
|
||||
}
|
||||
}
|
||||
t.Rows = append(t.Rows, rec)
|
||||
}
|
||||
|
||||
e.Tables[sheet] = t
|
||||
}
|
||||
|
||||
if len(e.Tables) == 0 {
|
||||
return nil, fmt.Errorf("no tables found in %s", filePath)
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *Engine) ListTables() []string {
|
||||
names := make([]string, 0, len(e.Tables))
|
||||
for n := range e.Tables {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (e *Engine) GetTable(name string) *Table {
|
||||
return e.Tables[name]
|
||||
}
|
||||
|
||||
func (e *Engine) IDColumn(table string) string {
|
||||
t, ok := e.Tables[table]
|
||||
if !ok || len(t.Columns) == 0 {
|
||||
return ""
|
||||
}
|
||||
return t.Columns[0]
|
||||
}
|
||||
|
||||
func (e *Engine) CreateSheet(name string) error {
|
||||
if _, exists := e.Tables[name]; exists {
|
||||
return fmt.Errorf("sheet %q already exists", name)
|
||||
}
|
||||
e.Tables[name] = &Table{
|
||||
Name: name,
|
||||
Columns: []string{"ID"},
|
||||
Rows: []map[string]any{},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) DropSheet(name string) error {
|
||||
if _, exists := e.Tables[name]; !exists {
|
||||
return fmt.Errorf("sheet %q not found", name)
|
||||
}
|
||||
delete(e.Tables, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) CreateTable(sheetName string, columns []string, rows []map[string]any) error {
|
||||
t, exists := e.Tables[sheetName]
|
||||
if !exists {
|
||||
return fmt.Errorf("sheet %q not found", sheetName)
|
||||
}
|
||||
t.Columns = columns
|
||||
t.Rows = rows
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) DropTable(name string) error {
|
||||
t, exists := e.Tables[name]
|
||||
if !exists {
|
||||
return fmt.Errorf("table %q not found", name)
|
||||
}
|
||||
t.Columns = nil
|
||||
t.Rows = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Flush() error {
|
||||
f, err := excelize.OpenFile(e.filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open for flush: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Merge: import any sheets from the file that our in-memory state doesn't know about.
|
||||
// This preserves external changes (e.g. edits made in Excel) across Flush calls.
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
if _, exists := e.Tables[sheet]; exists {
|
||||
continue
|
||||
}
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil || len(rows) < 2 {
|
||||
continue
|
||||
}
|
||||
t := &Table{
|
||||
Name: sheet,
|
||||
Columns: rows[0],
|
||||
Rows: make([]map[string]any, 0, len(rows)-1),
|
||||
}
|
||||
for _, row := range rows[1:] {
|
||||
rec := make(map[string]any, len(t.Columns))
|
||||
for i, col := range t.Columns {
|
||||
if i < len(row) {
|
||||
rec[col] = inferValue(row[i])
|
||||
} else {
|
||||
rec[col] = nil
|
||||
}
|
||||
}
|
||||
t.Rows = append(t.Rows, rec)
|
||||
}
|
||||
e.Tables[sheet] = t
|
||||
}
|
||||
|
||||
existing := f.GetSheetList()
|
||||
keep := make(map[string]bool)
|
||||
for name := range e.Tables {
|
||||
keep[name] = true
|
||||
}
|
||||
|
||||
for _, s := range existing {
|
||||
if !keep[s] {
|
||||
f.DeleteSheet(s)
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range e.Tables {
|
||||
if !sheetExists(f, t.Name) {
|
||||
f.NewSheet(t.Name)
|
||||
}
|
||||
rows, err := f.GetRows(t.Name)
|
||||
if err != nil {
|
||||
rows = nil
|
||||
}
|
||||
for i := range rows {
|
||||
for j := range rows[i] {
|
||||
cell, _ := excelize.CoordinatesToCellName(j+1, i+1)
|
||||
_ = f.SetCellValue(t.Name, cell, "")
|
||||
}
|
||||
}
|
||||
|
||||
for j, col := range t.Columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(j+1, 1)
|
||||
_ = f.SetCellStr(t.Name, cell, col)
|
||||
}
|
||||
|
||||
for i, row := range t.Rows {
|
||||
for j, col := range t.Columns {
|
||||
val, ok := row[col]
|
||||
if !ok || val == nil {
|
||||
continue
|
||||
}
|
||||
cell, _ := excelize.CoordinatesToCellName(j+1, i+2)
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
_ = f.SetCellStr(t.Name, cell, v)
|
||||
case float64:
|
||||
_ = f.SetCellFloat(t.Name, cell, v, 2, 64)
|
||||
case int:
|
||||
_ = f.SetCellInt(t.Name, cell, int64(v))
|
||||
default:
|
||||
_ = f.SetCellStr(t.Name, cell, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f.Save()
|
||||
}
|
||||
|
||||
func sheetExists(f *excelize.File, name string) bool {
|
||||
for _, s := range f.GetSheetList() {
|
||||
if s == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inferValue(s string) any {
|
||||
if i, err := strconv.Atoi(s); err == nil {
|
||||
return i
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/graphql-go/graphql"
|
||||
"github.com/restxlsx/restxlsx/internal/excel"
|
||||
"github.com/restxlsx/restxlsx/internal/logger"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
schema graphql.Schema
|
||||
engine *excel.Engine
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
func New(engine *excel.Engine, log *logger.Logger) (*Handler, error) {
|
||||
h := &Handler{engine: engine, Logger: log}
|
||||
schema, err := h.buildSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.schema = schema
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildSchema() (graphql.Schema, error) {
|
||||
fields := graphql.Fields{
|
||||
"tables": &graphql.Field{
|
||||
Type: graphql.NewList(graphql.String),
|
||||
Resolve: func(p graphql.ResolveParams) (any, error) {
|
||||
return h.engine.ListTables(), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, name := range h.engine.ListTables() {
|
||||
t := h.engine.GetTable(name)
|
||||
tableType := buildObjectType(name, t)
|
||||
|
||||
nameInner := name
|
||||
|
||||
fields[fmt.Sprintf("get_%s", name)] = &graphql.Field{
|
||||
Type: tableType,
|
||||
Args: graphql.FieldConfigArgument{
|
||||
"id": &graphql.ArgumentConfig{
|
||||
Type: graphql.String,
|
||||
},
|
||||
},
|
||||
Resolve: func(p graphql.ResolveParams) (any, error) {
|
||||
table := h.engine.GetTable(nameInner)
|
||||
if table == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if id, ok := p.Args["id"]; ok && id != "" {
|
||||
idStr := fmt.Sprintf("%v", id)
|
||||
idColInner := h.engine.IDColumn(nameInner)
|
||||
for _, row := range table.Rows {
|
||||
if fmt.Sprintf("%v", row[idColInner]) == idStr {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return table.Rows, nil
|
||||
},
|
||||
}
|
||||
|
||||
fields[fmt.Sprintf("list_%s", name)] = &graphql.Field{
|
||||
Type: graphql.NewList(tableType),
|
||||
Resolve: func(p graphql.ResolveParams) (any, error) {
|
||||
table := h.engine.GetTable(nameInner)
|
||||
if table == nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return table.Rows, nil
|
||||
},
|
||||
}
|
||||
|
||||
fields[fmt.Sprintf("create_%s", name)] = &graphql.Field{
|
||||
Type: tableType,
|
||||
Args: buildInputArgs(t),
|
||||
Resolve: func(p graphql.ResolveParams) (any, error) {
|
||||
table := h.engine.GetTable(nameInner)
|
||||
if table == nil {
|
||||
return nil, fmt.Errorf("table not found: %s", nameInner)
|
||||
}
|
||||
rec := make(map[string]any)
|
||||
for _, col := range table.Columns {
|
||||
if v, ok := p.Args[col]; ok {
|
||||
rec[col] = v
|
||||
}
|
||||
}
|
||||
idColInner := h.engine.IDColumn(nameInner)
|
||||
if idColInner != "" {
|
||||
if _, ok := rec[idColInner]; !ok {
|
||||
rec[idColInner] = len(table.Rows) + 1
|
||||
}
|
||||
}
|
||||
table.Rows = append(table.Rows, rec)
|
||||
if err := h.engine.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
queryType := graphql.NewObject(graphql.ObjectConfig{
|
||||
Name: "Query",
|
||||
Fields: fields,
|
||||
})
|
||||
|
||||
return graphql.NewSchema(graphql.SchemaConfig{
|
||||
Query: queryType,
|
||||
})
|
||||
}
|
||||
|
||||
func buildObjectType(name string, t *excel.Table) *graphql.Object {
|
||||
fields := graphql.Fields{}
|
||||
for _, col := range t.Columns {
|
||||
colName := col
|
||||
fields[colName] = &graphql.Field{
|
||||
Type: graphql.String,
|
||||
Resolve: func(p graphql.ResolveParams) (any, error) {
|
||||
if row, ok := p.Source.(map[string]any); ok {
|
||||
return fmt.Sprintf("%v", row[colName]), nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
return graphql.NewObject(graphql.ObjectConfig{
|
||||
Name: name,
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
func buildInputArgs(t *excel.Table) graphql.FieldConfigArgument {
|
||||
args := graphql.FieldConfigArgument{}
|
||||
for _, col := range t.Columns {
|
||||
args[col] = &graphql.ArgumentConfig{
|
||||
Type: graphql.String,
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var params struct {
|
||||
Query string `json:"query"`
|
||||
OperationName string `json:"operationName"`
|
||||
Variables map[string]any `json:"variables"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.Lock()
|
||||
result := graphql.Do(graphql.Params{
|
||||
Schema: h.schema,
|
||||
RequestString: params.Query,
|
||||
OperationName: params.OperationName,
|
||||
VariableValues: params.Variables,
|
||||
})
|
||||
h.engine.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
inner *slog.Logger
|
||||
quiet bool
|
||||
logReads bool
|
||||
logFile string
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Level slog.Level `json:"level"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Table string `json:"table,omitempty"`
|
||||
RecordID string `json:"record_id,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Duration string `json:"duration"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func New(quiet, logReads bool, logFile string) (*Logger, error) {
|
||||
var w io.Writer = os.Stdout
|
||||
if logFile != "" {
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w = io.MultiWriter(os.Stdout, f)
|
||||
}
|
||||
|
||||
var h slog.Handler
|
||||
if quiet {
|
||||
h = slog.NewTextHandler(io.Discard, nil)
|
||||
if logFile != "" {
|
||||
h = slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
}
|
||||
} else {
|
||||
h = slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
inner: slog.New(h),
|
||||
quiet: quiet,
|
||||
logReads: logReads,
|
||||
logFile: logFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *Logger) Log(ctx context.Context, method, path string, status int, dur time.Duration) {
|
||||
isRead := method == "GET" || method == "HEAD" || method == "OPTIONS"
|
||||
if isRead && !l.logReads {
|
||||
return
|
||||
}
|
||||
l.inner.LogAttrs(ctx, slog.LevelInfo,
|
||||
"request",
|
||||
slog.String("method", method),
|
||||
slog.String("path", path),
|
||||
slog.Int("status", status),
|
||||
slog.String("duration", dur.Round(time.Microsecond).String()),
|
||||
)
|
||||
}
|
||||
|
||||
func (l *Logger) Info(msg string, args ...any) {
|
||||
l.inner.Info(msg, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Error(msg string, args ...any) {
|
||||
l.inner.Error(msg, args...)
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(msg string, args ...any) {
|
||||
l.inner.Debug(msg, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user