chore: scaffold http client app plan implementation
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
frontend/node_modules/
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
backend/bin/
|
||||
|
||||
# Runtime/cache data
|
||||
http-client-cache/
|
||||
http-client-responses/
|
||||
http-client-events/
|
||||
http-client-logs/
|
||||
http-client-diagnostics/
|
||||
http-client-backups/
|
||||
|
||||
# Local databases and logs
|
||||
*.db
|
||||
*.db-*
|
||||
*.log
|
||||
|
||||
# OS/editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/local/http-client-app/backend/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server := &http.Server{
|
||||
Addr: "127.0.0.1:32180",
|
||||
Handler: api.NewRouter(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("HTTP Client local service listening on http://%s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
module github.com/local/http-client-app/backend
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/gin-gonic/gin v1.11.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/local/http-client-app/backend/internal/model"
|
||||
)
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{
|
||||
"status": "ok",
|
||||
}))
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func requestID(c *gin.Context) string {
|
||||
if requestID := c.GetHeader("X-Request-Id"); requestID != "" {
|
||||
return requestID
|
||||
}
|
||||
|
||||
return "req_local"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/local/http-client-app/backend/internal/api"
|
||||
)
|
||||
|
||||
func TestHealthEndpointReturnsEnvelope(t *testing.T) {
|
||||
router := api.NewRouter()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response is not JSON: %v", err)
|
||||
}
|
||||
|
||||
if body["success"] != true {
|
||||
t.Fatalf("expected success=true, got %#v", body["success"])
|
||||
}
|
||||
|
||||
if body["error"] != nil {
|
||||
t.Fatalf("expected error=null, got %#v", body["error"])
|
||||
}
|
||||
|
||||
data, ok := body["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data object, got %#v", body["data"])
|
||||
}
|
||||
|
||||
if data["status"] != "ok" {
|
||||
t.Fatalf("expected data.status=ok, got %#v", data["status"])
|
||||
}
|
||||
|
||||
if body["requestId"] == "" {
|
||||
t.Fatalf("expected requestId to be set")
|
||||
}
|
||||
|
||||
if body["timestamp"] == "" {
|
||||
t.Fatalf("expected timestamp to be set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details"`
|
||||
Location any `json:"location"`
|
||||
}
|
||||
|
||||
type Envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data"`
|
||||
Error *ErrorResponse `json:"error"`
|
||||
RequestID string `json:"requestId"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
func SuccessEnvelope(requestID string, data any) Envelope {
|
||||
return Envelope{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Error: nil,
|
||||
RequestID: requestID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HTTP Client App Internal API
|
||||
version: 0.1.0
|
||||
description: Internal REST API contract for the local HTTP Client application.
|
||||
servers:
|
||||
- url: http://127.0.0.1:32180
|
||||
description: Local development server
|
||||
paths:
|
||||
/api/health:
|
||||
get:
|
||||
operationId: getHealth
|
||||
summary: Check local service health
|
||||
tags:
|
||||
- system
|
||||
responses:
|
||||
"200":
|
||||
description: Service is healthy.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApiEnvelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HealthData"
|
||||
examples:
|
||||
healthy:
|
||||
value:
|
||||
success: true
|
||||
data:
|
||||
status: ok
|
||||
version: 0.1.0
|
||||
error: null
|
||||
requestId: req_01HZY7R4S7S0Y73ZJ81EF1Z8Y4
|
||||
timestamp: "2026-06-06T10:20:30Z"
|
||||
components:
|
||||
schemas:
|
||||
ApiEnvelope:
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- error
|
||||
- requestId
|
||||
- timestamp
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
description: Whether the request completed successfully.
|
||||
data:
|
||||
description: Response payload. Null when the request failed.
|
||||
error:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/ApiError"
|
||||
- type: "null"
|
||||
requestId:
|
||||
type: string
|
||||
minLength: 1
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
additionalProperties: false
|
||||
ApiError:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
minLength: 1
|
||||
message:
|
||||
type: string
|
||||
minLength: 1
|
||||
details:
|
||||
description: Optional structured error details.
|
||||
additionalProperties: false
|
||||
HealthData:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ok
|
||||
version:
|
||||
type: string
|
||||
uptimeSeconds:
|
||||
type: integer
|
||||
minimum: 0
|
||||
additionalProperties: false
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.local/http-client-app/contracts/events.schema.json",
|
||||
"title": "WebSocket Event Envelope",
|
||||
"description": "Base envelope for WebSocket events emitted by the local HTTP Client application.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"executionId",
|
||||
"seq",
|
||||
"timestamp",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"execution.response.chunk"
|
||||
]
|
||||
},
|
||||
"executionId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Monotonically increasing sequence number within an execution."
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"description": "Event-specific payload. Specialized schemas can narrow this object by event type."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.local/http-client-app/contracts/workspace-json-schemas/environments.schema.json",
|
||||
"title": "HTTP Client Environments",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"environments"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"defaultEnvironment": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"environments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"variables"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.local/http-client-app/contracts/workspace-json-schemas/mock-file.schema.json",
|
||||
"title": "HTTP Client Mock Rule File",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"updatedAt",
|
||||
"rules"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/mockRule"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"mockRule": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"enabled",
|
||||
"priority",
|
||||
"match",
|
||||
"response"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"match": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"method",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pathMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"exact",
|
||||
"prefix",
|
||||
"regex"
|
||||
],
|
||||
"default": "exact"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"query": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"statusCode"
|
||||
],
|
||||
"properties": {
|
||||
"statusCode": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"bodyType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"text",
|
||||
"binary",
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"type": [
|
||||
"string",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"bodyFilePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"delayMs": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"templateEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.local/http-client-app/contracts/workspace-json-schemas/openapi-sources.schema.json",
|
||||
"title": "HTTP Client OpenAPI Sources",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"sources"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"sourcePath",
|
||||
"format"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sourcePath": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"yaml"
|
||||
]
|
||||
},
|
||||
"openapiVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"contentHash": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastSyncedAt": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"targets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestFilePath": {
|
||||
"type": "string"
|
||||
},
|
||||
"mockFilePath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sync": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skip-local-modified",
|
||||
"overwrite-generated",
|
||||
"preview-only"
|
||||
],
|
||||
"default": "skip-local-modified"
|
||||
},
|
||||
"generateRequests": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"generateMocks": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"enableSchemaValidation": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"baseUrlVariable": {
|
||||
"type": "string",
|
||||
"default": "baseUrl"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://example.local/http-client-app/contracts/workspace-json-schemas/settings.schema.json",
|
||||
"title": "HTTP Client Workspace Settings",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"defaultEnvironment": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"history": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"retentionMaxCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 1000
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"mock": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"proxyFallback": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"upstreamBaseUrl": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "uri"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"hitLog": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"retentionMaxCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 10000
|
||||
},
|
||||
"retentionDays": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HTTP Client App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "http-client-app-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^2.1.9",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the application title', () => {
|
||||
const wrapper = mount(App)
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('HTTP Client App')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<main class="app-shell">
|
||||
<section class="hero" aria-labelledby="app-title">
|
||||
<p class="eyebrow">HTTP Client MVP</p>
|
||||
<h1 id="app-title">HTTP Client App</h1>
|
||||
<p class="subtitle">用于管理工作区、打开 .http 文件并预览请求调试流程的前端骨架。</p>
|
||||
</section>
|
||||
|
||||
<section class="status-card" aria-labelledby="backend-health-title">
|
||||
<div>
|
||||
<p class="section-label">后端健康状态</p>
|
||||
<h2 id="backend-health-title">等待接入健康检查</h2>
|
||||
</div>
|
||||
<span class="status-pill">占位</span>
|
||||
</section>
|
||||
|
||||
<section class="action-grid" aria-label="工作区入口">
|
||||
<button type="button" class="action-card">
|
||||
<span>打开工作区</span>
|
||||
<small>选择包含请求集合的目录</small>
|
||||
</button>
|
||||
<button type="button" class="action-card">
|
||||
<span>打开 .http 文件</span>
|
||||
<small>从单个请求文件开始</small>
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,144 @@
|
||||
:root {
|
||||
color: #1f2a37;
|
||||
background: #f3efe6;
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(235, 176, 104, 0.32), transparent 34rem),
|
||||
linear-gradient(135deg, #f8f4eb 0%, #e8dcc9 100%);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(960px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 56px 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 48px;
|
||||
border: 1px solid rgba(71, 51, 34, 0.14);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 252, 246, 0.76);
|
||||
box-shadow: 0 24px 80px rgba(65, 47, 29, 0.14);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.section-label {
|
||||
margin: 0 0 12px;
|
||||
color: #9b4b24;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 16px;
|
||||
color: #263326;
|
||||
font-size: clamp(2.5rem, 8vw, 5.8rem);
|
||||
line-height: 0.94;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 660px;
|
||||
margin-bottom: 0;
|
||||
color: #5f6a59;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.action-card {
|
||||
border: 1px solid rgba(71, 51, 34, 0.12);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 252, 246, 0.7);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-card h2 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
color: #6d3b19;
|
||||
background: #f3d9b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
min-height: 132px;
|
||||
padding: 24px;
|
||||
color: #263326;
|
||||
text-align: left;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-card span {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.action-card small {
|
||||
color: #66705f;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
param(
|
||||
[string]$DocumentPath = (Join-Path $PSScriptRoot "..\http-client-app-plan.md"),
|
||||
[string[]]$DisabledKeywords = @(
|
||||
"<<<<<<<",
|
||||
">>>>>>>",
|
||||
"TODO_BLOCKER",
|
||||
"FIXME_BLOCKER"
|
||||
)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$resolvedDocument = Resolve-Path -LiteralPath $DocumentPath
|
||||
$python = Get-Command python -ErrorAction SilentlyContinue
|
||||
$usePyLauncher = $false
|
||||
|
||||
if (-not $python) {
|
||||
$python = Get-Command py -ErrorAction SilentlyContinue
|
||||
$usePyLauncher = $true
|
||||
}
|
||||
|
||||
if (-not $python) {
|
||||
Write-Error "Python was not found. Install Python 3 or make the 'python'/'py' command available on PATH."
|
||||
}
|
||||
|
||||
$checker = @'
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def line_col(text, offset):
|
||||
line = text.count("\n", 0, offset) + 1
|
||||
last_newline = text.rfind("\n", 0, offset)
|
||||
column = offset + 1 if last_newline == -1 else offset - last_newline
|
||||
return line, column
|
||||
|
||||
|
||||
def check_code_fences(text):
|
||||
errors = []
|
||||
fence_pattern = re.compile(r"^[ \t]*```", re.MULTILINE)
|
||||
matches = list(fence_pattern.finditer(text))
|
||||
if len(matches) % 2 != 0:
|
||||
line, _ = line_col(text, matches[-1].start())
|
||||
errors.append(f"Unbalanced fenced code block near line {line}.")
|
||||
return errors
|
||||
|
||||
|
||||
def check_json_blocks(text):
|
||||
errors = []
|
||||
block_pattern = re.compile(r"^[ \t]*```json[^\n]*\n(.*?)(?:\n^[ \t]*```[ \t]*$)", re.MULTILINE | re.DOTALL)
|
||||
for index, match in enumerate(block_pattern.finditer(text), start=1):
|
||||
raw_json = match.group(1)
|
||||
try:
|
||||
json.loads(raw_json)
|
||||
except json.JSONDecodeError as exc:
|
||||
start_line, _ = line_col(text, match.start(1))
|
||||
errors.append(
|
||||
f"Invalid JSON code block #{index} at line {start_line + exc.lineno - 1}, "
|
||||
f"column {exc.colno}: {exc.msg}."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def is_table_separator(line):
|
||||
stripped = line.strip()
|
||||
if "|" not in stripped:
|
||||
return False
|
||||
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
||||
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)
|
||||
|
||||
|
||||
def pipe_count(line):
|
||||
stripped = line.strip()
|
||||
return stripped.count("|")
|
||||
|
||||
|
||||
def check_tables(text):
|
||||
errors = []
|
||||
lines = text.splitlines()
|
||||
in_fence = False
|
||||
for index, line in enumerate(lines):
|
||||
if re.match(r"^[ \t]*```", line):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence or not is_table_separator(line):
|
||||
continue
|
||||
|
||||
table_lines = []
|
||||
cursor = index - 1
|
||||
while cursor >= 0 and "|" in lines[cursor]:
|
||||
table_lines.insert(0, (cursor + 1, lines[cursor]))
|
||||
cursor -= 1
|
||||
table_lines.append((index + 1, line))
|
||||
cursor = index + 1
|
||||
while cursor < len(lines) and "|" in lines[cursor]:
|
||||
table_lines.append((cursor + 1, lines[cursor]))
|
||||
cursor += 1
|
||||
|
||||
counts = {pipe_count(table_line) for _, table_line in table_lines}
|
||||
if len(counts) > 1:
|
||||
first_line = table_lines[0][0]
|
||||
errors.append(f"Markdown table pipe count mismatch near line {first_line}.")
|
||||
return errors
|
||||
|
||||
|
||||
def check_disabled_keywords(text, keywords):
|
||||
errors = []
|
||||
for keyword in keywords:
|
||||
if not keyword:
|
||||
continue
|
||||
offset = text.find(keyword)
|
||||
if offset != -1:
|
||||
line, column = line_col(text, offset)
|
||||
errors.append(f"Disabled keyword '{keyword}' found at line {line}, column {column}.")
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Verify Markdown planning document structure.")
|
||||
parser.add_argument("document")
|
||||
parser.add_argument("--keyword", action="append", default=[])
|
||||
args = parser.parse_args()
|
||||
|
||||
document = Path(args.document)
|
||||
text = document.read_text(encoding="utf-8-sig")
|
||||
|
||||
errors = []
|
||||
errors.extend(check_code_fences(text))
|
||||
errors.extend(check_json_blocks(text))
|
||||
errors.extend(check_tables(text))
|
||||
errors.extend(check_disabled_keywords(text, args.keyword))
|
||||
|
||||
if errors:
|
||||
print(f"Documentation verification failed for {document}:")
|
||||
for error in errors:
|
||||
print(f" - {error}")
|
||||
return 1
|
||||
|
||||
print(f"Documentation verification passed for {document}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
'@
|
||||
|
||||
$tempChecker = Join-Path ([System.IO.Path]::GetTempPath()) ("verify-docs-{0}.py" -f ([System.Guid]::NewGuid().ToString("N")))
|
||||
|
||||
try {
|
||||
Set-Content -LiteralPath $tempChecker -Value $checker -Encoding UTF8
|
||||
|
||||
$arguments = @()
|
||||
if ($usePyLauncher) {
|
||||
$arguments += "-3"
|
||||
}
|
||||
$arguments += @($tempChecker, $resolvedDocument.Path)
|
||||
foreach ($keyword in $DisabledKeywords) {
|
||||
$arguments += @("--keyword", $keyword)
|
||||
}
|
||||
|
||||
& $python.Source @arguments
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $tempChecker) {
|
||||
Remove-Item -LiteralPath $tempChecker -Force
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user