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) <noreply@anthropic.com>
This commit is contained in:
148
hbrtl/httpx.go
Normal file
148
hbrtl/httpx.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -606,6 +606,14 @@ func RegisterRTL(vm *hbrt.VM) {
|
|||||||
hbrt.Sym("JSONHTTPGET", hbrt.FsPublic, JsonHttpGet),
|
hbrt.Sym("JSONHTTPGET", hbrt.FsPublic, JsonHttpGet),
|
||||||
hbrt.Sym("JSONHTTPPOST", hbrt.FsPublic, JsonHttpPost),
|
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
|
// Random
|
||||||
hbrt.Sym("HB_RANDOM", hbrt.FsPublic, HbRandom),
|
hbrt.Sym("HB_RANDOM", hbrt.FsPublic, HbRandom),
|
||||||
hbrt.Sym("HB_RANDOMINT", hbrt.FsPublic, HbRandomInt),
|
hbrt.Sym("HB_RANDOMINT", hbrt.FsPublic, HbRandomInt),
|
||||||
|
|||||||
91
hbrtl/xmlx.go
Normal file
91
hbrtl/xmlx.go
Normal file
@@ -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:
|
||||||
|
//
|
||||||
|
// <result>
|
||||||
|
// <list>
|
||||||
|
// <corp_code>00126380</corp_code>
|
||||||
|
// <corp_name>삼성전자(주)</corp_name>
|
||||||
|
// ...
|
||||||
|
// </list>
|
||||||
|
// <list>...</list>
|
||||||
|
// </result>
|
||||||
|
//
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
71
hbrtl/zipx.go
Normal file
71
hbrtl/zipx.go
Normal file
@@ -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("")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user