From 675eaa4def3243ee1b91d5ebc53dc3650835ec76 Mon Sep 17 00:00:00 2001 From: CharlesKWON Date: Fri, 29 May 2026 08:47:34 +0900 Subject: [PATCH] feat(hbrtl): FV_HTTPGET / FV_HTTPPOST / FV_ZIP* / FV_XML_ROWS New Five-native HTTP / ZIP / XML primitives so PRG code can do HTTPS fetch, ZIP container reads, and streaming XML row extraction without dropping into BEGINDUMP. FV_ prefix marks Five-original RTL (distinct from Harbour-inherited HB_ surface). FV_HTTPGET(cUrl [, hOpts]) / FV_HTTPPOST(cUrl, cBody [, hOpts]) hOpts: { headers: {=>}, timeout: nSec, tls_legacy: .T./.F. } Result: { status, body, error, headers } tls_legacy re-enables TLS_RSA cipher suites for legacy endpoints (DART OpenAPI pins them). FV_ZIPENTRIES(cZipBytes) / FV_ZIPREAD(cZipBytes, cEntryName) Read ZIP archives held in memory (e.g. from FV_HTTPGET). FV_XML_ROWS(cXml, cRowTag) Streaming reader for repeating-record XML. Each row becomes a flat hash of immediate-child element name -> text. Verified against DART corpCode.xml: 30 MB / 118k rows in seconds, no full-tree allocation. Co-Authored-By: Claude Opus 4.7 (1M context) --- hbrtl/httpx.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++ hbrtl/register.go | 8 +++ hbrtl/xmlx.go | 91 ++++++++++++++++++++++++++++ hbrtl/zipx.go | 71 ++++++++++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 hbrtl/httpx.go create mode 100644 hbrtl/xmlx.go create mode 100644 hbrtl/zipx.go diff --git a/hbrtl/httpx.go b/hbrtl/httpx.go new file mode 100644 index 0000000..0d86a48 --- /dev/null +++ b/hbrtl/httpx.go @@ -0,0 +1,148 @@ +// httpx.go — Five-native HTTP client (FV_HTTPGET / FV_HTTPPOST). +// +// Distinct from the existing JSONHTTPGET/POST helpers in this package: +// - bytes-safe body (ZIP/binary returns survive the round trip) +// - response headers exposed as a hash +// - per-request options (custom headers, timeout, TLS legacy ciphers) +// +// The tls_legacy option re-enables the older TLS_RSA cipher suites +// that some Korean government endpoints (e.g. DART OpenAPI) still +// require. Off by default — only flip it for servers you control or +// know need it. + +package hbrtl + +import ( + "crypto/tls" + "io" + "net/http" + "strings" + "time" + + "five/hbrt" +) + +// FV_HTTPGET(cUrl [, hOpts]) -> { status, body, error, headers } +func FvHttpGet(t *hbrt.Thread) { + nParams := t.ParamCount() + t.Frame(nParams, 0) + defer t.EndProc() + + url := t.Local(1).AsString() + var opts hbrt.Value + if nParams >= 2 { + opts = t.Local(2) + } + t.RetVal(doHttp("GET", url, nil, opts)) +} + +// FV_HTTPPOST(cUrl, cBody [, hOpts]) -> { status, body, error, headers } +func FvHttpPost(t *hbrt.Thread) { + nParams := t.ParamCount() + t.Frame(nParams, 0) + defer t.EndProc() + + url := t.Local(1).AsString() + body := []byte(t.Local(2).AsString()) + var opts hbrt.Value + if nParams >= 3 { + opts = t.Local(3) + } + t.RetVal(doHttp("POST", url, body, opts)) +} + +func doHttp(method, url string, body []byte, opts hbrt.Value) hbrt.Value { + hdrs, timeout, tlsLegacy := readHttpOpts(opts) + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + if tlsLegacy { + client.Transport = legacyTlsTransport() + } + + var bodyReader io.Reader + if body != nil { + bodyReader = strings.NewReader(string(body)) + } + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return makeFvHttpResult(0, nil, nil, err.Error()) + } + for k, v := range hdrs { + req.Header.Set(k, v) + } + if body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/octet-stream") + } + + resp, err := client.Do(req) + if err != nil { + return makeFvHttpResult(0, nil, nil, err.Error()) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return makeFvHttpResult(resp.StatusCode, nil, nil, err.Error()) + } + return makeFvHttpResult(resp.StatusCode, respBody, resp.Header, "") +} + +// readHttpOpts parses the optional options hash. Unknown keys are +// ignored so callers can pass extra metadata without us caring. +func readHttpOpts(opts hbrt.Value) (headers map[string]string, timeoutSec int, tlsLegacy bool) { + headers = map[string]string{} + timeoutSec = 30 + tlsLegacy = false + + if opts.IsNil() || !opts.IsHash() { + return + } + h := opts.AsHash() + + if v := h.HashGet(hbrt.MakeString("timeout")); !v.IsNil() { + timeoutSec = v.AsInt() + } + if v := h.HashGet(hbrt.MakeString("tls_legacy")); !v.IsNil() { + tlsLegacy = v.AsBool() + } + if v := h.HashGet(hbrt.MakeString("headers")); !v.IsNil() && v.IsHash() { + hh := v.AsHash() + for i, k := range hh.Keys { + headers[k.AsString()] = hh.Values[i].AsString() + } + } + return +} + +// legacyTlsTransport — TLS 1.2 with both modern ECDHE suites and the +// older TLS_RSA fallbacks. Required by some Korean government / +// financial endpoints (notably DART OpenAPI) that pin TLS_RSA. +func legacyTlsTransport() *http.Transport { + cfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + }, + } + return &http.Transport{TLSClientConfig: cfg} +} + +// makeFvHttpResult — { status, body, error, headers }. body is byte-safe. +func makeFvHttpResult(status int, body []byte, respHeaders http.Header, errMsg string) hbrt.Value { + hdrHash := &hbrt.HbHash{} + for k, vals := range respHeaders { + hdrHash.Append(hbrt.MakeString(strings.ToLower(k)), hbrt.MakeString(strings.Join(vals, ", "))) + } + out := &hbrt.HbHash{} + out.Append(hbrt.MakeString("status"), hbrt.MakeInt(status)) + out.Append(hbrt.MakeString("body"), hbrt.MakeStringBytes(body)) + out.Append(hbrt.MakeString("error"), hbrt.MakeString(errMsg)) + out.Append(hbrt.MakeString("headers"), hbrt.MakeHashFrom(hdrHash)) + return hbrt.MakeHashFrom(out) +} diff --git a/hbrtl/register.go b/hbrtl/register.go index a59fb16..5771ac8 100644 --- a/hbrtl/register.go +++ b/hbrtl/register.go @@ -606,6 +606,14 @@ func RegisterRTL(vm *hbrt.VM) { hbrt.Sym("JSONHTTPGET", hbrt.FsPublic, JsonHttpGet), hbrt.Sym("JSONHTTPPOST", hbrt.FsPublic, JsonHttpPost), + // Five-native HTTP / ZIP / XML (FV_ namespace = Five additions + // distinct from Harbour-inherited HB_ surface). + hbrt.Sym("FV_HTTPGET", hbrt.FsPublic, FvHttpGet), + hbrt.Sym("FV_HTTPPOST", hbrt.FsPublic, FvHttpPost), + hbrt.Sym("FV_ZIPENTRIES", hbrt.FsPublic, FvZipEntries), + hbrt.Sym("FV_ZIPREAD", hbrt.FsPublic, FvZipRead), + hbrt.Sym("FV_XML_ROWS", hbrt.FsPublic, FvXmlRows), + // Random hbrt.Sym("HB_RANDOM", hbrt.FsPublic, HbRandom), hbrt.Sym("HB_RANDOMINT", hbrt.FsPublic, HbRandomInt), diff --git a/hbrtl/xmlx.go b/hbrtl/xmlx.go new file mode 100644 index 0000000..60f1ebd --- /dev/null +++ b/hbrtl/xmlx.go @@ -0,0 +1,91 @@ +// xmlx.go — Five-native XML repeating-record reader (FV_XML_ROWS). +// +// Designed for one extremely common shape — a parent element wrapping +// many same-tagged children whose own children are leaf text fields: +// +// +// +// 00126380 +// 삼성전자(주) +// ... +// +// ... +// +// +// FV_XML_ROWS(cXml, "list") returns an array where each element is +// a hash { "corp_code" => "00126380", "corp_name" => "삼성전자(주)", ... }. +// +// Streaming — never materialises the full XML tree, so the 30MB DART +// corpCode dump (118k rows) doesn't blow PRG memory. + +package hbrtl + +import ( + "encoding/xml" + "strings" + + "five/hbrt" +) + +// FV_XML_ROWS(cXml, cRowTag) -> [ { field => text, ... }, ... ] +func FvXmlRows(t *hbrt.Thread) { + t.Frame(2, 0) + defer t.EndProc() + + data := t.Local(1).AsString() + rowTag := t.Local(2).AsString() + + dec := xml.NewDecoder(strings.NewReader(data)) + rows := []hbrt.Value{} + + for { + tok, err := dec.Token() + if err != nil { + break + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if se.Name.Local != rowTag { + continue + } + // Collect the row's immediate child elements as a flat hash. + row := &hbrt.HbHash{} + curField := "" + var curText strings.Builder + depth := 0 + for { + t2, err := dec.Token() + if err != nil { + break + } + switch tt := t2.(type) { + case xml.StartElement: + if depth == 0 { + curField = tt.Name.Local + curText.Reset() + } + depth++ + case xml.CharData: + if depth == 1 { + curText.Write([]byte(tt)) + } + case xml.EndElement: + depth-- + if depth == 0 && tt.Name.Local == curField && curField != "" { + row.Append(hbrt.MakeString(curField), hbrt.MakeString(curText.String())) + curField = "" + curText.Reset() + } + if depth < 0 { + // Closing tag for the row itself. + rows = append(rows, hbrt.MakeHashFrom(row)) + goto nextRow + } + } + } + nextRow: + } + t.RetVal(hbrt.MakeArrayFrom(rows)) +} diff --git a/hbrtl/zipx.go b/hbrtl/zipx.go new file mode 100644 index 0000000..0691c6f --- /dev/null +++ b/hbrtl/zipx.go @@ -0,0 +1,71 @@ +// zipx.go — Five-native ZIP container access (FV_ZIPENTRIES / FV_ZIPREAD). +// +// Reads an in-memory ZIP archive. No filesystem path — callers that +// downloaded the ZIP via FV_HTTPGET hand the raw bytes here directly, +// which is exactly the DART corpCode flow. + +package hbrtl + +import ( + "archive/zip" + "bytes" + "io" + + "five/hbrt" +) + +// FV_ZIPENTRIES(cZipBytes) -> [ { name, size }, ... ] +// Returns an empty array if the input isn't a valid ZIP. +func FvZipEntries(t *hbrt.Thread) { + t.Frame(1, 0) + defer t.EndProc() + + data := []byte(t.Local(1).AsString()) + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.RetVal(hbrt.MakeArrayFrom(nil)) + return + } + items := make([]hbrt.Value, 0, len(zr.File)) + for _, f := range zr.File { + row := &hbrt.HbHash{} + row.Append(hbrt.MakeString("name"), hbrt.MakeString(f.Name)) + row.Append(hbrt.MakeString("size"), hbrt.MakeInt(int(f.UncompressedSize64))) + items = append(items, hbrt.MakeHashFrom(row)) + } + t.RetVal(hbrt.MakeArrayFrom(items)) +} + +// FV_ZIPREAD(cZipBytes, cEntryName) -> cContents +// Returns "" if the ZIP is invalid or the entry isn't present. +func FvZipRead(t *hbrt.Thread) { + t.Frame(2, 0) + defer t.EndProc() + + data := []byte(t.Local(1).AsString()) + name := t.Local(2).AsString() + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.RetString("") + return + } + for _, f := range zr.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + t.RetString("") + return + } + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.RetString("") + return + } + t.RetVal(hbrt.MakeStringBytes(buf)) + return + } + t.RetString("") +}