From 450ae25c84a835bf512a9fb0a5f27aa6700c1b4d Mon Sep 17 00:00:00 2001 From: Ole Valente Date: Wed, 10 Jun 2026 13:01:57 +0200 Subject: [PATCH] initial commit --- .gitignore | 9 + Dockerfile | 12 + README.md | 324 +++++++++++++++++++++ data/sample.xlsx | Bin 0 -> 9112 bytes go.mod | 20 ++ go.sum | 32 ++ internal/api/ddl.go | 127 ++++++++ internal/api/middleware.go | 10 + internal/api/openapi.go | 311 ++++++++++++++++++++ internal/api/rest.go | 174 +++++++++++ internal/api/sql.go | 548 +++++++++++++++++++++++++++++++++++ internal/api/swagger-ui.html | 19 ++ internal/auth/auth.go | 83 ++++++ internal/config/config.go | 88 ++++++ internal/excel/excel.go | 245 ++++++++++++++++ internal/graphql/graphql.go | 173 +++++++++++ internal/logger/logger.go | 81 ++++++ main.go | 166 +++++++++++ scripts/build.sh | 27 ++ scripts/gen_data.py | 71 +++++ tasks.md | 93 ++++++ 21 files changed, 2613 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 data/sample.xlsx create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/ddl.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/openapi.go create mode 100644 internal/api/rest.go create mode 100644 internal/api/sql.go create mode 100644 internal/api/swagger-ui.html create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/excel/excel.go create mode 100644 internal/graphql/graphql.go create mode 100644 internal/logger/logger.go create mode 100644 main.go create mode 100755 scripts/build.sh create mode 100644 scripts/gen_data.py create mode 100644 tasks.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2aa8ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +restxlsx +restxlsx.exe +restxlsx-* +*.log +*.out +.DS_Store +Thumbs.db +dist/ +/tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cbae09 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8b97f5 --- /dev/null +++ b/README.md @@ -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 | diff --git a/data/sample.xlsx b/data/sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..43f2f199e45d04185db48896a9df0738f5fd84f5 GIT binary patch literal 9112 zcmb_?byQSs*FG)n&`39u(%ndRO7}=NlG2^hASK-;!jMW0NOvRM0@6cBe5k+g@sZbe zt?!Q?YYk_ewPs)E-h1|a-PhjxD9gdZVM9SdAwuz6^yx`{R*+7*zZ<;&0Pa6#_NK~C z_72X^j2#@9-ED0Yh80jcSWpp`TUA}_-$sz+qdymn$Z8p8eiaO&vkkgB*oPCg^YG{) z%w-OzpaG>y95Jukva@|g6PcpC66){EiO5;^s$^a8k9tq=IG>aRwlmju+Ao+Dm1khJ zzItbZS?KKpAk z_|`{Fc*=jcP#@Cb`}Kb5{m@WQ82@&`)ZPjB!-s-61*J|F)R4QrM6ccaK$6_b6mmr} za#kcTlWa>x?xnKH#J8n{KT&F!vkJ~-7vBcR7*w1V1}u- zB~eFl-ORGkqVN{l<%AeN4Luv!h=yws5)v5h$IFz<&ybwrl9VG>iF>o}qQVpH4Z9RE z=mB{FJxH$<>w?^1oU#u5_;G&W187x4|F8-0nl@-%Y;mzgp_26GMb25_yxSd%0h}#t z>utpi+@FDYtCv9uK!AeEpoN0Mx(|%I%`+DZpe^v%f7TzN+1Jr^p5wvxIj9a0i2}?Y7H=2DmKh<3SY;pMo8x@8q%afw2!#3msd4oC~OQ=pB?_Mm-&MyIxh;r`O z{Zo3&x&+TCr@q$CFK7l?+T`F%nHhN-Fj|O=sH^DM8UY-s#^nOZ}xuOZ<5cTs(FE*!Q5%WFsOJfQxuNCzUgWtHZvj?7b=)_hT<+C0k< zvl+--Wn5utw3^cRx%bj;x--&D3o*&HT~AD$x($(WGAJzv*QtRq*kl8`t5=Bxv&78{qI!o~-+03@Zo9kSrYy&v zBy&hQWyZpzLve#g<&|GoiGm`QJL1RmVyA}b>XZo((|Y{RqyoshzEs_ur8M{EvwLQG z^9@-i`dXGj7wtEv4<}qZZ?C^AN|;s<8**r2L^}FTscRfcYsZh}#7AT<_Ielsi7TLH z8$P&1-c=MZC)G@@)*l{T&x&%QY+it9c|(+>>?o&8+CETBCrVODyz60VTF2n2lR;y{ z5=P7nNwrcYg2_i>P0k6qsO>JtSjO_M|Ag4H6diV6W}hbbB1cPRF6aUvNwAvXyy-k@ z^ZikQ+9j(9UxiwX33%SZUuJM36U7k{!ko1)2{O{b>>hPj9IM5+)-LBv5Hjl78;pB3G!=)A z5pTeK|J|X;Sd3PZ5FXjI{ai;axSK44PMP-Yfc}>^4L%{0>^6)s=TZ}Rvg?m&iiCIA zI3DGtX(N3M(zJS48pf=D}2+S-oLc1NcGufo+jjG0{4FbCB zJ5@O@LX5+g0)|_)gDC9c^-gkML5ZL0Xn15#-w!w_lJukUn+wDfgWR%-Iln{nUDqiK zSkzL)8wkWc!P+)ue&OTEN{wqo9#@UE@4VdUawk~jorP=kPJLm!H8VQ;eXCuiI`2Rr znfP46(Vd<)b9F9kx>R4A(~E*X9d>b#_&BZcl6}RCzlrQ?bm;`WAuj+ck)XHFS@w zKUoRZ;wTQ^YFG5UL&Z?UMZuIdO~fmd%{_Ud_Gybj2CTu;D!~rIOu=ti+x+aPsMdh# z3VIB~*4WHj;t6CmFFamiLPcWK4b#S4RnXs`+)B@=^rUdTN^xs9I=*q}m3q9vzh!u; zFJ9>bSy;QWOW>E5&+zENfOe69p-dt|z{O)*SN;|si+}|^nArRL5GKH^kjO9f3gzQ5 zY9k%aP=ye$uLNUYh@!z5H70gMH7Yzgp3~aFL4uWA)btgjzip5@o7p5@pLlq?AiIn+8#*M)@3f{! zF4>lN9p?EKVGTC17{&8_+Pxzp{4mN)J~dS-T~cT8*LofKlcirz&F4cFmQi0%l<5?h zF<=PGJ$g+Vk6;?5d&*(>2HT{f@O?%194o4apVkR#&P$%9P>D1G_Q}X{-e_cd(MgdF z;rR(P3kVtqQ5?Jh>st_4-nt^Pz?a(twCWCU%*uXbEA*cS$lxNnC7F2jHv>8AOvD1lqZ*|As^YUg@LTSZ|(z0Ya&`y@?ph9-AQ5X{}IW1L#J z{`>PXRj7rj+>A?da&h4z?G+kFK@hggz?*m=JY%pL@kvI6uv^=O$5(`J{cUO>C7|Fk zt)t9&HOyt+b7?7+1jj$e(b z%6!(~#&bRW+-5vy6KbrZj4)jCz{6HFj{8)4JuW-2e(4ZW$MF%@ektBW65f0WIl72O zFMTf%Z7LHET-^z3kd|*DI}{re6|e|2B1VehN2K58D+w_1q?5Wh1B$T-57N2J(-#Jg*0(M1DPZxT*v-y8T4+#;hlW5LZeJ|By zXFK61>6wOWVJ74F%)-$j>kW-``eqjRsr|5(~zmmEmv&GF+uw~+*98 z*~aQCcCrA1s!T4S8kn%d>BEE4H1}Zz0L;}Bgf_jtHQe-#f;6`DsvD#1OM9LmvJ1AJ zTE@$G_D8sKpFXr-kgz(^yh28}I`*OHMItVa~3t))~s5@j;>xeGzpZmme{r{1u;n(Tt%J z(B|J{!~EkudRP!SPy_(KT*l}OC8d^+Sf)WRzYI^a+aL>hvw$(MPe~Q*k4?Aab=W@6 z?Mfww3N%nvNq5VsWr@s3F=1u*qSufof&e3C6ZTkj<;_UUo+!QY?taaYJuan&HVnQC zb4f(lKm2IP~(>D0AY9zc!L;*N~Z}&NN=Z9%Z ztkuNbg7?>!w(g?PXa&_=*QxyuZ#DBb+(=qch0*tbBZ$ayxJiQO?G~cyoGRtA@+HY^JW^R0DUH-thRjXGUOc~^+-h2xc8i)YIFG}f^?hCzs?)*a8%;>5`k=Cyo1NrXhJe6Dlay6Kz+5ZY}| zvnUS_=KN-);Ca1}hr3JCYkjavFvZ&_PZ_uN>g^Ggt7#Vj1E`vYxZEAz8V!8Y6!J=u zal8lY5A34ko>DheU@C72Uswyi#4z}%u*TMSrM~@~eGp9ATkbI948V#`*0LB! z*Eyz}JIG>yl#KPhY?2YGdAs5`(aAM#!yd`BQ#}~cN=(Q-UEPNSoOQ^MU|;Oyn3i{r zw45?dd%RS=RkLM_wws||823J~k8SS80j~S8J`Up&fPXp73l5nM*&fqFc(wqh!3EBC z4#r$6I9p?$5x4>;uMm3_pqj&9V+62}GKx%HHc(WmYrsbJJXOtRnz>7~sC~L_@1<~( zQ2q{FAZaQRTN>ydPRLxrTwNYJ(|N0N+hU$VtdH~Z4(iWzr<6-J+P~kkgMa6?pQ&ye ztMY@}o>!UdLp!MHP?b_)B1?RIgowdxw5nig>_zF77e9|BMkt-6^v2xvT%qr4XD$n0 zASYrK(aS>uX*V1xz#Mq0^maoQUCaD1b`Nq+Mxaj6NtlWJRlq#f@4{*7Gb+KRD=n~` zz=eKMInw8%gpQ}Hlbr}?(-|9sM#0Z8T{fBN(&yfzHpwdZq#yrfzgbqyIj$N1R$j4> zo1Jmt8;;)K%|rLh^zqv^CO*4oF=DJFbf=M(gND|HIwZB3ibvFHXS`|azOl}Q>x--H z(=L!!rB!yT@x~ngnGwsU&Fe-wQIwk;jCTlsh6g|t|K;D6_r8fzg z3K5L#d3MFD(^QUJhun)-w`x$C7)7qVzOwgvo^9lg1}V zpujvWeXecQ2oeObC9W5?7H_(O4VU8!WYGCdM%elRm-PYI?@|g)4QNRc)CNkC`VFzS z;k=BL;PDP~CNIL{QJvD3zmPfTrJWC5x0g^)8v4*W6jqpvw+uqBnX}GF%8lbj24IWP zye7Lnfw7iWTZwqKanM!f5lNr8zg6}<6dB5J30g&K?NkZ#y}PBN0%ZUty&5EJcxmNW zbkUDp>Q;t@vt9cwRqfgt5;p_RDQyM*T=p!N=v*BB;!$j{=F6l<92 zI-kU%59$=nVh|;f@1kOFKW~*y{19}rJ7VLx6w2<0J{qW4YaW)QuBk<2+&OB?)0Bkr z{@md8XHaJF+1SfXD~d0nPw=hfBbY5}l)8Xqy1+F_yx}^1dn10>&%3MHYWr*XPszSQ zv)YKD0s)mUCdEicOkI!MIYAyX5@N1=>e96pSukRFRnV+EFga@QvBzNQk547hBI2UN zv>{gcNHv342*zK!L?JwyJhc^-O_VO9R)B9JdsX#hE~UilD)H|;S=`YOl)70|S=X72G*#(FjWXE+7r2u@#l$T#;lnR7Za-~ zZOn<(%EL$B8=}Zjt56|1eg)}B0k0jJ@VL<-8FP5Wr zq{aj%`yI<{t~#}kG10yu&F7P14@puK%A6+kE3PIZ&ak*b`E7O5l9Uf2o${O6uUjYg z7=pzhC1xTcAWy5ViLU0{Z6WbjrRNNVrb~7}P?SkwcEgOYJ|lMP*Ca8nGF@@PT7*?g zNQfrHXWvEVTwThbu>x~+VdaV;WL0wzyH`NPV_VF?qajl&Tyx5hvwVulpq5S;z)Q`` zsmkMI4_OEzRmuMZnT8FCEUn-*>aNqaGgBZCNyF1N_}zKMNp9y5G1^tej#QgsW-KC*|a=~SS8`}N3az^(6} zBtt8G)^vH#!MA_s;GcW)Rot#!APZ`+A$1=tW5Il^CbX)AMv1U%!LwNZCvi@q@AG2n z4Z+CtSl{0!9Lxy0N*=4hswoT|@O~H*88Y;SCE>NkhUlO_Lm3rMKk=^Nr$I6&?kVXtk^$^Cq8kfCgh`bp#bW(G$n?8>+J=G86j*CCSdC*UWp-!LDm$Gs3- zZg|_Mbp-oosAf=5vHnkd{4;3(kB=3QJ6L{5%^+TpBKn3{5Ciu@vVoC&kqoOZ73IXd z?uZ0D9`g^WX^rqhYIZS~6i+rQ>r_+h1ZdR)l?o!lJ3#}iUhzyX1H(@Ga&VKn3du8lFFFe2>8& zNvhsUO{sgSiS|o3JG*$?*A+iVctLYMVx9$XgR}OyI3YEyfy$$pT-l<()z>rkm1ZST9s8O`g(`QN5@+8e*AC7(6Jz9_#l z;;R%KBPdHfsa!DkHs`hWq@a7t^zs9Rn*6rQhq1sp`e|Bzc8hvNbIW*J&5F_K5rJsv z)?~V@7Pdx31{w)l3B7^D*dlzxpq?RVQ>?8?hg`MdI~=?Lsg(Sf#>A2C5DVP?AgKc^ z6uM{hxR$6kn#9gAx*yLJse-q?Y-$Y^Ghe)~-{UZREpLy%veG}%&Glumj!-;3X}y9n z1n0XrOUBN&3gK21MHz;TJx8SMQ0?epqnlozIbRj&zP`U{Iym}nEJ&~@X1MSLXBz9^ zwzB`c3+rX;);_q*@!g}PSoGWBcAi@^4~Cd_VURtXp4B62w~c3`L6X2spCXJzZr-n= z(zK`B#LveaZa(q9TY5kD_);IbwRDAvFyE`uZ}-&7cP$!U-9!8Ad31DHCFBKPpNC_n znxXu52&so!bmhWyyL`(^f6NP`mSE5d48TkRAOhE+ql{oCK~jDu45%iqbUWghiA$3q?r+5~Pj3+Hmmm!*yrcZFbbmnH0N+aT6KAOt4@@+oBf@NXl@=Zl^ z?@KU+)y=|JnK=dzVZlLSd4Na=P*W7>0@yq%R4@~{DzN1OMb06}E7n5LNz_3W((R|X z@N>lhb`A!I)O{y4Z~%W1$t>|OgBc{mJ~DDZ-^m5J_laQt&{E^x)wJUY;h#IHUTQa@ z?LO&n?-LN^{`#v-`?+H>M=*QE0ObjflW6HSZ`L7vjVEdn$9T8F;|P8Q%-~_ha-06YzDx%c|zU8 z)M+3d004b zaz+-LD<$E;cp8^4AL*J{C6u-BDgUIv)TGvZY5VSEq!?T9vNe;XAbf0E0*S`H`tm_R zHxyhosB+J&<@b(0{F#|U_e%pzR7RZ%@05JDT9N(T?-E5deF~6*) z@MpWBE0OV#nh9<7SAWTm+8`0>^i=)aRuqUX$ajsPRvgzSrtn?LrXqj+t{P?)9a1OA5`EpdqkVUW2)BT`NKNBCy^KOaFz>j-HogWPf3Ys z=6W6pH9d_n->eH1TWIVagC@)8%xz)2k;T-84HWo1vz)Y3A}$s+SX+P!oxtwbi)1v2 z;O_42xp`IV_Nvw-TkP>BZ-AH;*r@-abZ0w5p1G=fnze0eZoatU2Fvm!eelQ_Px#L? zLqXI>Xt|F%>AmhG_!a$MnfCAB;AhqiCh(#MvH&E0bWl*`;wb{uEiEXSjoPcjx{}Rv z+x7#qq2%bsvh!a*U%@t9j?SxTIPL~L0w zu+ZCU1Ou*a;rFu0?T@nPYI>KspX(8Zm^I?>^&f?q_#D=61T`_I55nI~PNB4+o&L{*SC1)KIVw5CPmU1eL|raqZ-& zJOfQniQly$2inY3?L}$cd50Fn*S^n93dEyfVL$lj{ibbiZ5cY@<0$TyrNsKX;I*nQMmJf!D5_6rgeyRUezdS1vtueJb0o&-l6Gml4|q>EG}VrqwmuhUWEMel6!z8- z+ca#JoEntrVV-vl;kIgZFQGoM?|xFA_!;AIemIW|hORFg5#Ev;Ab_4syH~`1>@ao4 z+TUfwZ`LCUi(X&5ln6P+9<9o38@30A0g8>!T2xAK0!$w9cp6n31ZwF?VtHm+=~g&%bP;p!}da|84!hljw)$4=1$0&A;3~{x9>|hX4;d^#1_(6C?fv z_*WzCcfbB2%EJcbKPb><_tgHkcI88ahb_F{2tlY1*YVee-b28LZM1&?{Z;Oz&i#@e zI`*@Geu(n0JMj++w8;I-4i6mrE5gG9{x^c*{ksN#uH#>h{VwMpF6m(r{STl&Bj!WE zpTF2oCHfHMq2&Ju1saU=ACCPM;h_@#jc|bba2@}0?6)p{xTJ?_^fzD&-cP{a+Vr96 zLoM;!l=q(Ve?9#t$3HZDcsBnwq#*pqiT^#R9~wVAT7Da66a6&)_2B;Z)G|7duIRfvI+)NARH9b9m$Wo{{gX=sdE4T literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..edd74d0 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ec51df --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/ddl.go b/internal/api/ddl.go new file mode 100644 index 0000000..7ccf788 --- /dev/null +++ b/internal/api/ddl.go @@ -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"}) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..c782dae --- /dev/null +++ b/internal/api/middleware.go @@ -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) + }) +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go new file mode 100644 index 0000000..3bee24f --- /dev/null +++ b/internal/api/openapi.go @@ -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)) +} diff --git a/internal/api/rest.go b/internal/api/rest.go new file mode 100644 index 0000000..1815f05 --- /dev/null +++ b/internal/api/rest.go @@ -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) + } +} diff --git a/internal/api/sql.go b/internal/api/sql.go new file mode 100644 index 0000000..76b9e42 --- /dev/null +++ b/internal/api/sql.go @@ -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 +} diff --git a/internal/api/swagger-ui.html b/internal/api/swagger-ui.html new file mode 100644 index 0000000..4d48335 --- /dev/null +++ b/internal/api/swagger-ui.html @@ -0,0 +1,19 @@ + + + + + + restxlsx - Swagger UI + + + +
+ + + + diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8d726af --- /dev/null +++ b/internal/auth/auth.go @@ -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) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5d1d1d6 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/excel/excel.go b/internal/excel/excel.go new file mode 100644 index 0000000..a49ec47 --- /dev/null +++ b/internal/excel/excel.go @@ -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 +} diff --git a/internal/graphql/graphql.go b/internal/graphql/graphql.go new file mode 100644 index 0000000..408dd5a --- /dev/null +++ b/internal/graphql/graphql.go @@ -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) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..de3501d --- /dev/null +++ b/internal/logger/logger.go @@ -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...) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6c9f497 --- /dev/null +++ b/main.go @@ -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(` + + + + GraphiQL + + + + +
Loading...
+ + + +`) diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..13b5c9a --- /dev/null +++ b/scripts/build.sh @@ -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}/" diff --git a/scripts/gen_data.py b/scripts/gen_data.py new file mode 100644 index 0000000..9f149d8 --- /dev/null +++ b/scripts/gen_data.py @@ -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)") diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..335672f --- /dev/null +++ b/tasks.md @@ -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) |