Parser Design¶
The SDK's parsers turn APNIC's text, XML, and JSON wire formats into typed Go models. They are deliberately decoupled from the transport: a parser takes either an io.Reader (streaming) or a string (full-buffer) and has no knowledge of HTTP, caching, or chunked download. This separation lets the transport layer (fetchReader / fetchText / fetchTextStr) choose the right fetch strategy and the parser stay a pure function of its input.
Sources: fetcher.go, bgp.go, irr.go, rrdp.go, rdap.go, rex.go.
Naming Convention¶
Parsers follow a small set of naming patterns:
| Pattern | Input | Example | Used for |
|---|---|---|---|
parseXFull(io.Reader) | stream | parseDelegatedFull | Large, line-oriented files (delegated stats). Returns the full result including header + summaries + entries. |
parseX(string) | full buffer | parseIRRDatabase, parseBGPRawTable, parseBGPUsedAutnums | Files that need whole-string operations (last-comma search, tab/whitespace fallback) or are small enough to buffer. |
parseXFromReader(io.Reader) | stream | parseRRDPNotification, parseRPKISnapshot | XML streams decoded with xml.Decoder.Token(). |
json.Decode inline | stream | doRDAPRequestAt, fetchJSON | RDAP / REx JSON, decoded directly from the response body. |
The Full suffix marks the streaming, complete-result variant; the no-suffix parseX(string) form is for full-buffer parsers. There is no parseX(io.Reader) without Full in the text-parser family — the distinction is load-bearing.
Streaming vs. Full-Buffer¶
graph TB
subgraph Fetch["Fetch helper (chosen by parser)"]
FetchReader["fetchReader<br/>→ io.Reader<br/>(chunked, streaming)"]
FetchText["fetchText<br/>→ string<br/>(single GET + gzip)"]
FetchTextStr["fetchTextStr<br/>→ string<br/>(fetchReader + buffer)"]
FetchJSON["fetchJSON<br/>→ json.Decode(out)<br/>(single GET + gzip)"]
end
subgraph Parser["Parser signature"]
Stream["parseXFull(io.Reader)"]
Str["parseX(string)"]
XML["parseX(io.Reader) via xml.Decoder"]
JSON["json.Unmarshal / Decode"]
end
subgraph Model["Typed model"]
M1["DelegatedResult"]
M2["IRRDatabase / BGP tables"]
M3["RRDPNotification / RPKISnapshot"]
M4["RDAPNetwork / RExHolder"]
end
FetchReader --> Stream --> M1
FetchText --> Str --> M2
FetchTextStr --> Str --> M2
FetchJSON --> JSON --> M4
FetchReader --> XML --> M3
FetchText --> XML Why streaming matters¶
The delegated-stats file and RRDP snapshots are multi-megabyte. A streaming parser scans line-by-line (or token-by-token for XML) and holds only the accumulated result in memory, not the raw file. parseDelegatedFull uses bufio.Scanner over the io.Reader returned by fetchReader; peak memory is the result slice plus the scanner's line buffer. parseRPKISnapshot uses xml.Decoder.Token() to walk the snapshot element-by-element, discarding the large base64 CMS bodies of <publish> elements so only the rsync URIs are retained.
When full-buffer is the right call¶
Thyme BGP files and IRR dumps are parsed from a string. For IRR, the RPSL continuation-line folding and last-comma country extraction are easier over the whole string. For BGP, parseBGPUsedAutnums needs strings.LastIndex(line, ",") and a substring slice that's awkward to do on a raw byte stream. These files are fetched via fetchTextStr, which reuses the chunked fetchReader transport and then materializes the merged stream into a strings.Builder — so large IRR dumps still get the chunked-download speedup, they just buffer the result before parsing.
fetchReader vs fetchText vs fetchTextStr¶
| Helper | Returns | Buffers? | Chunked? | Typical parser |
|---|---|---|---|---|
fetchReader | io.Reader | No | Yes (or single-stream fallback) | parseDelegatedFull, RRDP snapshot |
fetchText | string | Yes (whole body) | No (single GET) | Small BGP files (data-summary, data-badpfx-nos), APNIC.CURRENTSERIAL, RRDP notification.xml |
fetchTextStr | string | Yes (whole body) | Yes (via fetchReader + buffer) | Large BGP files (data-raw-table, data-used-autnums), IRR dumps |
fetchJSON | decoded out | No (streaming decode) | No | REx endpoints |
The choice is per-parser and reflects a trade-off: streaming keeps memory bounded but complicates whole-string operations; buffering simplifies parsing at the cost of holding the file in memory. The SDK streams the biggest files (delegated, RRDP) and buffers the rest.
Parser Pipeline¶
flowchart LR
Call["Fetch* method"] --> BuildURL["buildXURL(...)"]
BuildURL --> Fetch["fetchReader / fetchText / fetchTextStr / fetchJSON"]
Fetch -->|"io.Reader"| Scan
Fetch -->|"string"| Str
Fetch -->|"json body"| Dec
subgraph Stream["Streaming text parse"]
Scan["bufio.Scanner<br/>scanner.Buffer(64KB, 4MB)"]
Scan --> LineLoop["for scanner.Scan()<br/>line = TrimSpace(Text())"]
LineLoop --> Skip{"skip blank / # / header?"}
Skip -->|"yes"| LineLoop
Skip -->|"no"| FieldSplit["Split | / tab / whitespace"]
FieldSplit --> Guard{"len(parts) >= N?"}
Guard -->|"no"| LineLoop
Guard -->|"yes"| Build["build entry<br/>(parse sub-values)"]
Build --> Append["append to result"]
Append --> LineLoop
end
subgraph Str["Full-buffer parse"]
Str["bufio.Scanner over<br/>strings.NewReader(data)"]
Str --> StrLoop["for scanner.Scan()<br/>(same defensive loop)"]
end
subgraph XML["XML stream"]
Dec["xml.Decoder.Token()"]
Dec --> Tok{"token type"}
Tok -->|"StartElement"| Attr["match local name<br/>snapshot/publish/withdraw"]
Tok -->|"CharData"| Discard["discard base64 body"]
end
Scan --> Model["typed model"]
Str --> Model
Dec --> Model
Model --> Caller["return to caller / cache.set"] Every text parser uses bufio.Scanner with an enlarged buffer: scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) — a 64 KB initial buffer that grows to 4 MB. RPSL lines and BGP route lines can be long, and the default 64 KB Scanner cap would panic on a line exceeding it. The 4 MB ceiling is a safety bound; lines longer than 4 MB are treated as a scan error.
Defensive Boundary Handling¶
Parsers are written to skip malformed lines rather than fail the whole parse. APNIC's files occasionally contain header rows, separators, column labels, and oddities the parser doesn't model; the strategy is to recognize and skip them, parsing only the lines that match the expected shape.
The general pattern, repeated across every parser:
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue // blank, comment, or dash separator
}
// ... split and validate ...
if len(parts) < N { continue } // too few fields
// ... build entry, skipping on parse errors ...
}
Example: parseBGPUsedAutnums slice-bounds defense¶
data-used-autnums lines look like:
The parser extracts:
asn= first whitespace field ("1").country= text after the last comma ("US").FullName= the text between the ASN and the comma ("LVLT-1 - Level 3 Parent, LLC").
The naive implementation would slice line[len(asn):commaIdx], but that panics if the comma appears at or before the ASN — for example a malformed line "1, Foo" or ", foo", where len(asn) > commaIdx and the slice would go out of range. The parser guards explicitly:
commaIdx := strings.LastIndex(line, ",")
if commaIdx < 0 {
continue
}
country := strings.TrimSpace(line[commaIdx+1:])
// Guard against a comma that appears at or before the ASN (e.g. "1, Foo"
// or ", foo"), where len(asn) > commaIdx would slice out of range.
if len(asn) > commaIdx {
continue
}
rest := strings.TrimSpace(line[len(asn):commaIdx])
This is the defensive style used throughout: validate the indices you are about to slice on, and continue on any shape that would let the slice escape its bounds. The parse never panics on a malformed line.
Other boundary defenses¶
| Parser | Defense |
|---|---|
parseDelegatedFull | len(parts) < 7 → skip; per-field parse errors (parseIPv4Count, etc.) → skip the entry, not the whole file. |
parseBGPRawTable | Tab split first; if not exactly 2 fields, fall back to strings.Fields (any-whitespace); still not 2 → skip. |
parseBGPBadPrefixes | Skip the column header row by name ("Origin" / "Address"); require exactly 2 whitespace fields. |
parseBGPPerPrefixLength | Each /N:count token parsed independently; a token that fails strconv.Atoi is skipped, not fatal. |
parseBGPSparPrefixes | Tab split with fallback to whitespace; description is fields[2:] joined, so it may contain spaces. |
parseBGPSinglePfx | Non-numeric prefix/ASN counts skipped; RIR is fields[2:] joined. |
parseIRRDatabase | Colon-less lines skipped; continuation lines with no active attribute ignored; +-prefix continuation folds without extra space. |
parseRPKISnapshot | Namespace-agnostic element matching via localName / attrLocal; CharData (base64 bodies) discarded. |
Error Handling¶
Parsers distinguish scan errors (the bufio.Scanner hit an I/O or buffer error) from skipped lines (a malformed line that the parser deliberately ignored):
- Skipped lines never produce an error — the parse continues and returns the valid entries.
scanner.Err()is checked at the end and wrapped (e.g."IRR database scan failed: %w"). A non-nil scan error means the stream was truncated or a line exceeded the 4 MB buffer; the result so far is still returned in some parsers but the error surfaces.
JSON and XML parsers propagate decode errors with context:
| Parser | Error wrapping |
|---|---|
parseRRDPNotification | "RRDP notification XML decode failed: %w" |
parseRPKISnapshot | "RPKI snapshot XML stream failed: %w" |
doRDAPRequestAt | "JSON decode failed: %w"; RDAP 404 → ErrNotFound with the server's error title; other non-200 → status-coded error. |
fetchJSON (REx) | "JSON decode failed: %w"; non-200 includes the plain-text server message in the error. |
Detailed Flow: IRR Database Parse¶
IRR dumps are RPSL text: objects separated by blank lines, attributes as key: value lines, with continuation lines (leading whitespace or +) folded into the preceding attribute. parseIRRDatabase(objType, data) builds an IRRDatabase of IRRObjects, each with a Type, PrimaryKey, and multi-valued Attributes map.
flowchart TD
Start["parseIRRDatabase(objType, data)"] --> Init["db = &IRRDatabase{Type, Objects: []}<br/>cur = nil, lastKey = ''"]
Init --> Scan["bufio.Scanner over data<br/>buffer 64KB→4MB"]
Scan --> Loop{"scanner.Scan()?"}
Loop -->|"false"| Flush["flush() — append last object"]
Flush --> Err{"scanner.Err()?"}
Err -->|"nil"| Done["return db"]
Err -->|"err"| Wrap["return 'IRR scan failed: %w'"]
Loop -->|"true"| Raw["raw = Text()"]
Raw --> Blank{"blank line?"}
Blank -->|"yes"| FlushMid["flush() — append cur, reset lastKey"]
FlushMid --> Loop
Blank -->|"no"| Cmt{"comment (# after trimleft)?"}
Cmt -->|"yes"| Loop
Cmt -->|"no"| Cont{"raw[0] is space/tab?"}
Cont -->|"yes (continuation)"| ContGuard{"cur != nil AND lastKey != ''?"}
ContGuard -->|"no"| Loop
ContGuard -->|"yes"| Fold["strip leading +<br/>else prefix with space<br/>append to cur.Attributes[lastKey]"]
Fold --> Loop
Cont -->|"no (attribute line)"| Colon{"':' in raw?"}
Colon -->|"no"| Loop
Colon -->|"yes"| KV["key = lower(trim raw[:colon])<br/>val = trim raw[colon+1:]"]
KV --> NewObj{"cur == nil?"}
NewObj -->|"yes"| Create["cur = &IRRObject{Type: key,<br/>PrimaryKey: val,<br/>Attributes: map[]}"]
NewObj -->|"no"| AppendAttr["cur.Attributes[key] = append(...)"]
Create --> AppendAttr
AppendAttr --> SetLast["lastKey = key"]
SetLast --> Loop The continuation-line folding is the subtle part: a line beginning with whitespace (or +) belongs to the previous attribute. A leading + suppresses the implicit extra space (RPSL convention); otherwise a space is prepended so the folded value reads naturally. This parser is independent of ParseWhoisResponse because IRR dumps are bulk multi-object files, while whois responses are typically single objects with a different surrounding format.
Detailed Flow: BGP data-raw-table¶
data-raw-table is the largest thyme file — every BGP route as prefix\tASN. parseBGPRawTable allocates a result slice with capacity 1_000_000 (the file is that large) and scans line-by-line:
flowchart LR
Fetch["FetchBGPRawTable"] --> Str["fetchTextStr → string"]
Str --> Parse["parseBGPRawTable(data)"]
Parse --> Alloc["t.Routes cap=1,000,000"]
Alloc --> Loop["for each line"]
Loop --> Trim["TrimSpace; skip empty"]
Trim --> Tab{"Split('\t') == 2?"}
Tab -->|"no"| WS["fallback: strings.Fields == 2?"]
Tab -->|"yes"| Add1["append BGPRoute{Prefix, ASN}"]
WS -->|"yes"| Add1
WS -->|"no"| Loop
Add1 --> Loop
Loop --> End["return t, scanner.Err()"] The tab-first, whitespace-fallback split is defensive against mixed delimiters in the source file. The large preallocation avoids repeated slice growth for a million-entry result.
Summary¶
- Parsers are pure functions of
io.Readerorstring, decoupled from transport. - Large files stream (
parseXFull,xml.Decoder); smaller or whole-string-needing files buffer (parseX(string)). fetchReader(streaming, chunked),fetchText(string, single GET), andfetchTextStr(string, chunked) are the three fetch strategies a parser chooses from.- Every parser skips malformed lines and guards slice indices — the parse never panics on bad input.
- Scan errors are wrapped with context; skipped lines are silent.
Next¶
- Caching — where the parsed result lands after this pipeline.
- Chunked Download — the transport behind
fetchReader/fetchTextStr.