initial commit

This commit is contained in:
2026-06-10 13:01:57 +02:00
commit 450ae25c84
21 changed files with 2613 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
restxlsx
restxlsx.exe
restxlsx-*
*.log
*.out
.DS_Store
Thumbs.db
dist/
/tmp/
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.24-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /restxlsx .
FROM scratch
COPY --from=builder /restxlsx /restxlsx
COPY data/sample.xlsx /data/sample.xlsx
EXPOSE 8080
ENTRYPOINT ["/restxlsx"]
+324
View File
@@ -0,0 +1,324 @@
# restxlsx
A RESTful web service that exposes Microsoft Excel (.xlsx / .xlsm) tables as a live API. Supports REST CRUD, GraphQL, SQL queries, and DDL operations — all backed by an xlsx file as the data store.
---
## Quick Start
```bash
# Use the sample data
./restxlsx
# Or point at your own file
./restxlsx --file data/sample.xlsx
# Open http://localhost:3000/health
```
---
## Configuration
All settings can be provided via CLI flags **or** environment variables. Flags take precedence over env vars.
| Flag | Env Var | Default | Description |
|---|---|---|---|
| `--file` | `RESTXLSX_FILE` | `data/sample.xlsx` | Path to the xlsx/xlsm data file |
| `--host` | `RESTXLSX_HOST` | `localhost` | Interface to bind to (`0.0.0.0` for all) |
| `--port` | `RESTXLSX_PORT` | `3000` | TCP port |
| `--debug` | `RESTXLSX_DEBUG` | `false` | Enable OpenAPI spec, Swagger UI, GraphiQL |
| `--quiet` | `RESTXLSX_CONSOLE=quiet` | `false` | Suppress all stdout output |
| `--log-reads` | `RESTXLSX_LOG_READS` | `true` | Log GET/HEAD requests (`false` to skip) |
| `--log-file` | `RESTXLSX_LOG_FILE` | `""` | Optional path to append logs to file |
| `--auth` | `RESTXLSX_AUTH` | `writer` | Fallback auth role (see Authorization below) |
**Examples:**
```bash
# Listen on all interfaces, port 8080, reader-only
./restxlsx --host 0.0.0.0 --port 8080 --auth reader
# Using env vars
RESTXLSX_FILE=./myfile.xlsx RESTXLSX_DEBUG=true ./restxlsx
# Quiet mode, no read logging, log to file
./restxlsx --quiet --log-reads=false --log-file /var/log/restxlsx.log
```
---
## Endpoints
### Health
```
GET /health
```
### REST CRUD — `GET/POST/PUT/DELETE /api/{table}[/{id}]`
| Method | Path | Description | Auth |
|---|---|---|---|
| `GET` | `/api/tables` | List all discovered table names | reader+ |
| `GET` | `/api/{table}` | List all records in a table | reader+ |
| `GET` | `/api/{table}/{id}` | Get a single record by its first-column ID | reader+ |
| `POST` | `/api/{table}` | Create a new record | writer+ |
| `PUT` | `/api/{table}/{id}` | Update an existing record | writer+ |
| `DELETE` | `/api/{table}/{id}` | Delete a record | writer+ |
The first column of each table is treated as the unique ID column. If omitted on create, an auto-increment
value is assigned.
### GraphQL — `POST /graphql`
Query and mutate data via GraphQL. Dynamic schema is generated from the tables at startup.
```graphql
# Query
{ list_Employees { ID Name Salary } }
# Get single
{ get_Employees(id: "1") { Name Department } }
# Mutate
mutation { create_Employees(ID: "99", Name: "New", Department: "QA", Salary: "70000") { ID } }
```
When `--debug` is enabled, `GET /graphql` serves the GraphiQL IDE.
### SQL — `POST /api/sql`
Execute SQL statements against the data. Supports SELECT, INSERT, UPDATE, DELETE with optional WHERE.
```json
// SELECT
{"query": "SELECT Name, Salary FROM Employees WHERE ID = '1'"}
// SELECT all
{"query": "SELECT * FROM Employees"}
// INSERT
{"query": "INSERT INTO Employees (ID, Name, Department) VALUES ('42', 'Alice', 'Eng')"}
// UPDATE
{"query": "UPDATE Employees SET Salary = '100000' WHERE ID = '42'"}
// DELETE
{"query": "DELETE FROM Employees WHERE ID = '42'"}
```
The SQL dialect is intentionally minimal — only `=` comparisons in WHERE, string/number values,
and single statements per request. This maps directly to the in-memory table operations.
### DDL — `POST/DELETE /api/ddl/*`
Schema management (admin only).
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/ddl/sheets` | Create a new sheet (empty, with a default `ID` column) |
| `DELETE` | `/api/ddl/sheets/{sheet}` | Delete a sheet and all its data |
| `POST` | `/api/ddl/tables` | Define columns on a sheet (effectively creates a table schema) |
| `DELETE` | `/api/ddl/tables/{table}` | Remove all data from a table (keeps the sheet) |
**Create sheet:**
```json
POST /api/ddl/sheets
{"name": "MyNewSheet"}
```
**Define table on a sheet:**
```json
POST /api/ddl/tables
{"sheet": "MyNewSheet", "name": "Projects", "columns": ["ID", "Name", "Budget"]}
```
### OpenAPI / Swagger (debug mode)
When `--debug` is set:
| Endpoint | Description |
|---|---|
| `GET /openapi.json` | Auto-generated OpenAPI 3.0.3 specification |
| `GET /swagger` | Swagger UI interactive documentation |
---
## Authorization
Authorization is implemented as a **role-based** system with three levels. The `--auth` flag (or `RESTXLSX_AUTH`
env var) sets the *fallback* role that applies to all requests. This is the effective authorization until
OIDC/OAuth2 integration is added (see below).
### Role hierarchy
| Role | Read | Write (POST/PUT/DELETE) | DDL (create/drop) |
|---|---|---|---|
| `reader` | ✓ | — | — |
| `writer` (default) | ✓ | ✓ | — |
| `admin` | ✓ | ✓ | ✓ |
### For unauthenticated / open access
To run the service with no authentication barrier (everyone can read and write):
```bash
./restxlsx --auth writer
```
This is **the default** — all callers are treated as `writer`. They can read and mutate data but
cannot modify the schema. If you also need DDL access:
```bash
./restxlsx --auth admin
```
For read-only public access:
```bash
./restxlsx --auth reader
```
### Future: OIDC / OAuth2 integration
The auth architecture (`internal/auth/`) is designed to be upgraded to token-based claims.
The current `--auth` fallback role will become the default for **unauthenticated** requests.
**Planned integration path:**
1. The `auth.Middleware` will first inspect the `Authorization` header for a Bearer JWT.
2. If a valid token is present, the `roles` claim (or a custom claim mapping) is extracted
and the effective role becomes the token's role.
3. If no token is present (or the token is invalid/expired), the fallback `--auth` role applies.
4. A future `--oidc-issuer`, `--oidc-client-id`, and `--oidc-claim-roles` config will wire
the validation.
**Middleware structure** (ready for extension):
```
internal/auth/
├── auth.go # Role type, helpers, RequireWrite/RequireDDL middlewares
├── oidc.go # (future) OIDC token validation, claim extraction
└── middleware.go # (future) Combined middleware: try OIDC, fallback to role
```
The `auth.Middleware` currently injects a static role into the request context.
To add OIDC, you would:
1. Parse the `Bearer` token in the middleware
2. Validate against the OIDC issuer's JWKS
3. Map the `roles` claim to a `Role`
4. Store it in context (same `roleKey{}`)
5. All downstream `RequireWrite` / `RequireDDL` checks work unchanged
---
## Data Persistence & Concurrency
The xlsx file on disk is the persistent store. The workflow:
1. On startup, the xlsx file is read into memory
2. All operations read from the in-memory snapshot (fast, no file I/O on reads)
3. Every write operation mutates memory **and** immediately flushes to the xlsx file
4. The file is **not held open** between flushes — other applications (Excel, etc.)
can freely edit the file while the service is running
5. During a flush, the service merges any new sheets that were added externally,
so external additions are preserved
6. If the file cannot be opened for writing (e.g., locked by another process),
the operation fails with `503 Service Unavailable`
7. In-memory data is protected by a `sync.Mutex`, so concurrent HTTP requests
are serialized at the engine level — safe for concurrent access
> **Note:** Updates to existing rows from an external editor will be overwritten
> by the next flush from this service. For collaborative editing, keep the xlsx
> as the primary source and use this service as a proxy.
---
## Docker
```bash
# Build
docker build -t restxlsx .
# Run (default sample data included)
docker run -p 3000:3000 restxlsx
# With your own xlsx file
docker run -p 3000:3000 -v /path/to/your.xlsx:/data/sample.xlsx restxlsx
```
The Dockerfile uses a multi-stage build resulting in a ~15 MB scratch image.
---
## Development
### Prerequisites
- Go 1.24+
- Python 3.10+ (only needed for sample data generation)
### Regenerate sample data
```bash
python3 -m venv /tmp/venv && /tmp/venv/bin/pip install openpyxl
/tmp/venv/bin/python scripts/gen_data.py
```
### Project layout
```
restxlsx/
├── main.go # Entry point, router, graceful shutdown
├── Dockerfile # Multi-stage scratch build
├── internal/
│ ├── config/config.go # CLI flags + env var configuration
│ ├── logger/logger.go # Structured logging via slog
│ ├── excel/excel.go # XLSX engine: read, write, flush, DDL
│ ├── auth/auth.go # Role-based authorization middleware
│ ├── graphql/graphql.go # Dynamic GraphQL schema & handler
│ └── api/
│ ├── rest.go # REST CRUD handlers
│ ├── sql.go # SQL endpoint + minimal SQL parser
│ ├── ddl.go # DDL handlers (create/drop sheets/tables)
│ ├── openapi.go # Dynamic OpenAPI spec generation
│ ├── middlewares.go # JSON content-type middleware
│ └── swagger-ui.html # Embedded Swagger UI
├── data/sample.xlsx # Sample workbook (3 tables)
├── scripts/gen_data.py # Python script to generate sample data
└── tasks.md # Architecture decisions & progress log
```
## Building from Source
### Cross-platform builds
The `scripts/build.sh` script cross-compiles binaries for Linux, Windows, and macOS:
```bash
chmod +x scripts/build.sh
./scripts/build.sh
```
Binaries are placed in the `dist/` directory:
| File | Platform |
|------|----------|
| `dist/restxlsx-linux-amd64` | Linux (x86_64) |
| `dist/restxlsx-windows-amd64.exe` | Windows (x86_64) |
| `dist/restxlsx-darwin-amd64` | macOS (Intel) |
| `dist/restxlsx-darwin-arm64` | macOS (Apple Silicon) |
### Manual build
```bash
go build -o restxlsx .
```
### Dependency rationale
| Library | Purpose |
|---|---|
| `github.com/go-chi/chi/v5` | Lightweight, idiomatic HTTP router |
| `github.com/xuri/excelize/v2` | Full xlsx read/write support |
| `github.com/graphql-go/graphql` | Dynamic schema generation from data |
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
module github.com/restxlsx/restxlsx
go 1.24.4
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/graphql-go/graphql v0.8.1
github.com/xuri/excelize/v2 v2.10.1
)
require (
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
+32
View File
@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+127
View File
@@ -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"})
}
+10
View File
@@ -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)
})
}
+311
View File
@@ -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))
}
+174
View File
@@ -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)
}
}
+548
View File
@@ -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
}
+19
View File
@@ -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>
+83
View File
@@ -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)
})
}
+88
View File
@@ -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
}
+245
View File
@@ -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
}
+173
View File
@@ -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(&params); 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)
}
+81
View File
@@ -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...)
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/restxlsx/restxlsx/internal/api"
"github.com/restxlsx/restxlsx/internal/auth"
"github.com/restxlsx/restxlsx/internal/config"
"github.com/restxlsx/restxlsx/internal/excel"
"github.com/restxlsx/restxlsx/internal/graphql"
"github.com/restxlsx/restxlsx/internal/logger"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
log, err := logger.New(cfg.Quiet, cfg.LogReads, cfg.LogFile)
if err != nil {
fmt.Fprintf(os.Stderr, "logger error: %v\n", err)
os.Exit(1)
}
log.Info("starting restxlsx",
"file", cfg.FilePath,
"host", cfg.Host,
"port", cfg.Port,
"debug", cfg.Debug,
"auth", cfg.AuthRole,
)
engine, err := excel.Open(cfg.FilePath)
if err != nil {
log.Error("failed to open excel file", "error", err)
os.Exit(1)
}
tables := engine.ListTables()
log.Info("discovered tables", "tables", tables)
authHandler := auth.New(cfg.AuthRole)
r := chi.NewRouter()
r.Use(chiMiddleware.RequestID)
r.Use(chiMiddleware.RealIP)
r.Use(chiMiddleware.Logger)
r.Use(chiMiddleware.Recoverer)
r.Use(authHandler.Middleware)
writeRoute := auth.RequireWrite
ddlRoute := auth.RequireDDL
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// REST API
restHandler := api.NewRestAPI(engine, log)
r.Get("/api/tables", restHandler.ListTables)
r.Route("/api/{table}", func(r chi.Router) {
r.Get("/", restHandler.ListRecords)
r.Post("/", writeRoute(restHandler.CreateRecord))
r.Get("/{id}", restHandler.GetRecord)
r.Put("/{id}", writeRoute(restHandler.UpdateRecord))
r.Delete("/{id}", writeRoute(restHandler.DeleteRecord))
})
// GraphQL
gqlHandler, err := graphql.New(engine, log)
if err != nil {
log.Error("failed to create graphql handler", "error", err)
os.Exit(1)
}
r.Post("/graphql", writeRoute(gqlHandler.ServeHTTP))
if cfg.Debug {
r.Get("/graphql", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(graphiQLPage)
})
oapi := api.NewOpenAPI(engine, cfg.Debug)
r.Get("/openapi.json", oapi.Handler)
r.Get("/swagger", oapi.SwaggerHandler)
r.Get("/swagger/*", oapi.SwaggerHandler)
}
// SQL endpoint
sqlHandler := api.NewSQLHandler(engine, log)
r.Post("/api/sql", writeRoute(sqlHandler.ServeHTTP))
// DDL endpoints (admin only)
r.Route("/api/ddl", func(r chi.Router) {
ddl := api.NewDDLHandler(engine, log)
r.Post("/sheets", ddlRoute(ddl.CreateSheet))
r.Delete("/sheets/{sheet}", ddlRoute(ddl.DropSheet))
r.Post("/tables", ddlRoute(ddl.CreateTable))
r.Delete("/tables/{table}", ddlRoute(ddl.DropTable))
})
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Info("listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("server error", "error", err)
os.Exit(1)
}
}()
<-quit
log.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error("shutdown error", "error", err)
os.Exit(1)
}
log.Info("stopped")
}
var graphiQLPage = []byte(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>GraphiQL</title>
<style>
body { margin: 0; height: 100vh; }
#graphiql { height: 100vh; }
</style>
<link rel="stylesheet" href="https://unpkg.com/graphiql/graphiql.min.css" />
</head>
<body>
<div id="graphiql">Loading...</div>
<script src="https://unpkg.com/graphiql/graphiql.min.js" crossorigin></script>
<script>
const fetcher = graphiql.createFetcher({ url: '/graphql' });
ReactDOM.render(React.createElement(graphiql.GraphiQL, { fetcher }), document.getElementById('graphiql'));
</script>
</body>
</html>`)
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
LDFLAGS="-s -w -X main.version=${VERSION}"
OUTDIR="$(dirname "$0")/../dist"
mkdir -p "$OUTDIR"
echo "Building restxlsx ${VERSION}..."
echo ""
build() {
local os="$1" arch="$2" suffix="${3:-}"
local binary="${OUTDIR}/restxlsx-${os}-${arch}${suffix}"
echo " -> ${binary}"
GOOS="${os}" GOARCH="${arch}" CGO_ENABLED=0 go build -ldflags="${LDFLAGS}" -o "${binary}" .
}
build linux amd64
build linux arm64
build windows amd64 ".exe"
build darwin amd64
build darwin arm64
echo ""
echo "Done. Binaries in ${OUTDIR}/"
ls -lh "${OUTDIR}/"
+71
View File
@@ -0,0 +1,71 @@
import openpyxl
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo
wb = openpyxl.Workbook()
# ── Sheet: Employees ──────────────────────────────────────────
ws1 = wb.active
ws1.title = "Employees"
headers1 = ["ID", "Name", "Department", "Salary", "HiredDate"]
ws1.append(headers1)
employees = [
[1, "Alice Chen", "Engineering", 95000, "2021-03-15"],
[2, "Bob Martinez", "Marketing", 78000, "2022-07-01"],
[3, "Carol Singh", "Engineering", 105000, "2020-11-20"],
[4, "Dave Okafor", "Sales", 72000, "2023-01-10"],
[5, "Eve Thompson", "HR", 65000, "2022-09-05"],
[6, "Frank Wu", "Engineering", 115000, "2019-06-17"],
[7, "Grace Kim", "Marketing", 82000, "2021-12-01"],
[8, "Henry Patel", "Sales", 88000, "2020-04-22"],
]
for row in employees:
ws1.append(row)
tab1 = Table(displayName="Employees", ref=f"A1:E{len(employees)+1}")
tab1.tableStyleInfo = TableStyleInfo(name="TableStyleMedium9", showRowStripes=True)
ws1.add_table(tab1)
# ── Sheet: Products ───────────────────────────────────────────
ws2 = wb.create_sheet("Products")
headers2 = ["ID", "Name", "Category", "Price", "InStock"]
ws2.append(headers2)
products = [
[101, "Widget Alpha", "Widgets", 9.99, 250],
[102, "Widget Beta", "Widgets", 14.99, 180],
[103, "Gadget Gamma", "Gadgets", 29.99, 75],
[104, "Gadget Delta", "Gadgets", 49.99, 42],
[105, "Doohickey Eps", "Doohickeys", 5.99, 500],
[106, "Doohickey Zeta", "Doohickeys", 7.99, 320],
[107, "Widget Eta", "Widgets", 19.99, 0],
[108, "Gadget Theta", "Gadgets", 39.99, 15],
]
for row in products:
ws2.append(row)
tab2 = Table(displayName="Products", ref=f"A1:E{len(products)+1}")
tab2.tableStyleInfo = TableStyleInfo(name="TableStyleMedium9", showRowStripes=True)
ws2.add_table(tab2)
# ── Sheet: Orders ─────────────────────────────────────────────
ws3 = wb.create_sheet("Orders")
headers3 = ["OrderID", "Customer", "ProductID", "Quantity", "OrderDate", "Status"]
ws3.append(headers3)
orders = [
[1001, "Acme Corp", 101, 10, "2025-01-15", "Shipped"],
[1002, "Globex Inc", 103, 5, "2025-01-17", "Delivered"],
[1003, "Initech", 106, 20, "2025-02-01", "Pending"],
[1004, "Acme Corp", 102, 8, "2025-02-10", "Shipped"],
[1005, "Umbrella Co", 107, 15, "2025-03-05", "Cancelled"],
[1006, "Globex Inc", 108, 3, "2025-03-12", "Processing"],
[1007, "Initech", 104, 2, "2025-04-01", "Delivered"],
[1008, "Acme Corp", 105, 50, "2025-04-15", "Pending"],
[1009, "Umbrella Co", 101, 12, "2025-05-01", "Shipped"],
[1010, "Globex Inc", 104, 7, "2025-05-20", "Processing"],
]
for row in orders:
ws3.append(row)
tab3 = Table(displayName="Orders", ref=f"A1:F{len(orders)+1}")
tab3.tableStyleInfo = TableStyleInfo(name="TableStyleMedium9", showRowStripes=True)
ws3.add_table(tab3)
wb.save("data/sample.xlsx")
print("Generated data/sample.xlsx with 3 tables (Employees, Products, Orders)")
+93
View File
@@ -0,0 +1,93 @@
# restxlsx — Task Progress & Decisions
## Architecture Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Language | Go 1.24 | Cross-platform, single-binary, great HTTP/stdlib |
| Excel parsing | `excelize/v2` | The de-facto Go xlsx library, full read/write support |
| HTTP router | `go-chi/chi/v5` | Lightweight, idiomatic, stdlib-compatible, middleware-friendly |
| GraphQL | `graphql-go/graphql` | Pure Go, no code-gen, dynamic schema from data |
| OpenAPI generation | Manual struct-to-spec | Schema is dynamic (based on xlsx tables), no code-gen tool fits |
| Swagger UI | Embedded static files | Serves swagger-ui in debug mode via `embed` |
| Logging | `log/slog` (stdlib) | Structured logging, no deps, configurable levels |
| Config | `flag` + env vars | Zero-dependency, clean; flags override env vars override defaults |
| ID field | First column of each table | Simple convention; assumed to be unique |
| Data storage | In-memory (read from xlsx at startup) | No external DB needed; lightweight proxy to the file |
| GraphQL schema | Dynamic per-table | Generated at startup from the xlsx table structure |
| xlsx generation | Python + openpyxl | Best Python library for creating rich xlsx files |
## Completed Tasks
- [x] Plan architecture & create tasks.md
- [x] Generate sample xlsx file with tables (Employees, Products, Orders)
- [x] Initialize Go module & install dependencies (chi, excelize, graphql-go)
- [x] Implement configuration (CLI flags + env vars: --file, --port, --debug, --quiet, --log-reads, --log-file)
- [x] Implement logging (stdout via slog, --quiet suppresses, RESTXLSX_LOG_READS=false skips reads)
- [x] Implement Excel file parsing engine (open, list tables, read/write rows, flush to disk)
- [x] Implement RESTful CRUD API endpoints (GET/POST/PUT/DELETE per table)
- [x] Implement GraphQL endpoint (dynamic Query type with get_/list_/create_ per table)
- [x] Implement dynamic OpenAPI spec + Swagger UI (/openapi.json, /swagger in debug mode)
- [x] Wire everything in main.go (chi router, graceful shutdown, signal handling)
- [x] Build and test the service (all endpoints verified with curl)
- [x] Create multi-stage Dockerfile (scratch image, 8080 exposed)
- [x] Add auth middleware with admin/writer/reader roles
- [x] Change default host to localhost:3000, add --host flag
- [x] Add /api/sql endpoint (basic SQL query translation)
- [x] Add /api/ddl/* endpoints (create/drop tables and sheets)
- [x] Add JSON Content-Type middleware
- [x] Wire auth into all endpoints (REST, GraphQL, SQL, DDL)
- [x] Change default auth from admin to writer
- [x] Create comprehensive README.md with configuration docs
## Log
### 2026-06-10 Initial Build
- Created full Go project from scratch
- Used Python/openpyxl to generate sample.xlsx with 3 tables
- All deps: chi v5.3.0, excelize v2.10.1, graphql-go v0.8.1
- REST API, GraphQL, OpenAPI, Swagger UI all verified working
- Config via flags/env vars with sensible defaults
- Graceful shutdown on SIGINT/SIGTERM
- Multi-stage Dockerfile included
### 2026-06-10 Auth, SQL, DDL, README
- Added `--auth` flag (admin/writer/reader) with middleware enforcement
- Changed default port to 3000, added `--host` flag (default localhost)
- Added `POST /api/sql` endpoint with minimal SQL parser (SELECT, INSERT, UPDATE, DELETE)
- Added DDL endpoints under `/api/ddl/` for create/drop sheets and tables
- Added JSON Content-Type middleware for consistent API responses
- Changed default auth role from admin to writer
- Enhanced OpenAPI spec with SQL and DDL endpoints, server URL fix
- Created comprehensive README.md with config tables, auth docs, OIDC integration path
## Usage
```bash
# Run directly
./restxlsx --file data/sample.xlsx --port 8080
# With debug mode (enables /openapi.json and /swagger)
./restxlsx --debug
# Quiet mode (no stdout)
./restxlsx --quiet
# Or via env vars
RESTXLSX_FILE=data/sample.xlsx RESTXLSX_DEBUG=true ./restxlsx
```
## Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /health | Health check |
| GET | /api/tables | List discovered tables |
| GET | /api/{table} | List records |
| POST | /api/{table} | Create record |
| GET | /api/{table}/{id} | Get record |
| PUT | /api/{table}/{id} | Update record |
| DELETE | /api/{table}/{id} | Delete record |
| POST | /graphql | GraphQL query |
| GET | /openapi.json | OpenAPI spec (debug only) |
| GET | /swagger | Swagger UI (debug only) |