feat(connectors): add the http connector and wire the network gate

TDD step 7: read_http executes the optional http extension point behind
an injectable transport seam (urllib_get is the only socket path; the
suite runs socket-free against a mock). credential_ref resolves from the
environment at run time, fail-fast before any transport call; the secret
rides in the Authorization header only. Explicit single-slash URL join,
CRLF-to-LF normalization, LF line-count cap (missing trailing newline
still counts), and code-fence-marker rejection. materialize_bundle
renders the body as a verbatim fenced block; the §8 gate test asserts
zero transport calls without the opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:06:21 +02:00
commit ab1fa4d999
3 changed files with 242 additions and 6 deletions

View file

@ -9,13 +9,22 @@ from __future__ import annotations
import csv
import os
import sqlite3
from collections.abc import Callable
from contextlib import closing
from pathlib import Path
from urllib.error import URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from .errors import SourceError
from .render import sql_value_to_text
#: The http transport seam: ``(url, credential) -> response body text``.
#: Injecting a canned implementation in tests keeps the suite socket-free
#: (§11); the default ``urllib_get`` is the ONLY path that opens a socket,
#: and it is reached only inside the gated http branch of materialization.
HttpGet = Callable[[str, "str | None"], str]
def safe_resolve(root: Path, relative: str) -> Path:
"""Resolve `relative` against `root`, fail-closed (the OKF path rule).
@ -116,3 +125,73 @@ def read_sql(
except (sqlite3.Error, sqlite3.Warning) as exc:
raise SourceError(f"sql extraction failed for query {query!r}: {exc}") from exc
return header, rows
def urllib_get(url: str, credential: str | None) -> str:
"""The stdlib GET — the only socket path in this library.
Attaches `Authorization: Bearer {credential}` iff a credential was
resolved. Transport/decode failures wrap as SourceError WITHOUT the
secret the credential rides in the header, never in the URL
(embedded credentials are already rejected at schema validation).
"""
headers = {"Authorization": f"Bearer {credential}"} if credential is not None else {}
request = Request(url, headers=headers, method="GET")
try:
with urlopen(request) as response:
raw: bytes = response.read()
return raw.decode("utf-8")
except (URLError, OSError, UnicodeDecodeError) as exc:
raise SourceError(f"http GET failed for {url!r}: {exc}") from exc
def read_http(
base_url: str,
query: str,
*,
max_rows: int,
credential_ref: str | None = None,
get: HttpGet = urllib_get,
) -> str:
"""Execute an `http`-source extraction: GET `base_url` joined with `query`
via the injectable `get` seam, returning the body for verbatim fenced
rendering (§4/§5).
`credential_ref` names an environment variable holding the secret,
resolved at run time (present-but-unset is fail-fast, before any
transport call; None means no auth) never read from the manifest,
never logged, never stamped. URL join is explicit (one slash), not
urljoin. The body is CRLFLF normalized and capped on its LF line count
(a missing trailing newline still counts the last line); exceeding
max_rows is an ERROR (§8). A body line that is a code-fence marker is
rejected it cannot be safely embedded in a fenced block; fail-fast,
never silent corruption.
"""
if credential_ref is None:
credential: str | None = None
else:
credential = os.environ.get(credential_ref)
if not credential:
raise SourceError(
f"http source credential_ref {credential_ref!r} is not set in the environment "
"(credentials resolve at run time, never from the manifest — §4)"
)
url = base_url.rstrip("/") + "/" + query.lstrip("/")
normalized = get(url, credential).replace("\r\n", "\n").replace("\r", "\n")
for line in normalized.split("\n"):
if line.lstrip(" ").startswith("```"):
raise SourceError(
f"http extraction {query!r} contains a code-fence marker line (```) — "
"cannot be safely embedded in a fenced code block; fail-fast, never "
"silent corruption"
)
newline_count = normalized.count("\n")
line_count = (
newline_count if (normalized == "" or normalized.endswith("\n")) else newline_count + 1
)
if line_count > max_rows:
raise SourceError(
f"http extraction {query!r} exceeds max_rows={max_rows} "
"(error, never silent truncation — spec §8)"
)
return normalized

View file

@ -15,8 +15,8 @@ import re
from dataclasses import dataclass
from pathlib import Path
from .connectors import read_csv, read_sql, safe_resolve
from .errors import ManifestError, MaterializationError, NetworkGateError, SourceError
from .connectors import HttpGet, read_csv, read_http, read_sql, safe_resolve, urllib_get
from .errors import ManifestError, MaterializationError, NetworkGateError
from .manifest import (
Extraction,
FileSource,
@ -26,7 +26,7 @@ from .manifest import (
generated_filename,
load_manifest_bytes,
)
from .render import render_table
from .render import render_fenced_block, render_table
_LOGGER = logging.getLogger(__name__)
@ -148,6 +148,7 @@ def materialize_bundle(
ingested_at: str,
*,
allow_network: bool = False,
http_get: HttpGet | None = None,
) -> IngestResult:
"""Materialize a manifest's extractions into an OKF bundle (§5).
@ -155,7 +156,9 @@ def materialize_bundle(
verbatim) no wall-clock default; this is what makes golden extractions
bit-deterministic. `allow_network` is the §8 per-run network opt-in: an
`http` source is refused fail-fast unless it is set the manifest itself
cannot grant network access. Source calls are logged per §8 (which
cannot grant network access. `http_get` optionally injects the transport
seam (default urllib_get, the only socket path; ignored for `file`/`sql`)
so tests run socket-free (§11). Source calls are logged per §8 (which
source, when, row count) never cell contents, never secrets.
"""
if not _INGESTED_AT_RE.match(ingested_at):
@ -206,8 +209,17 @@ def materialize_bundle(
)
body = render_table(header, rows)
row_count = len(rows)
else: # HttpSource — transport lands with the http connector step.
raise SourceError("http transport is not implemented yet")
else: # HttpSource — the gate above guarantees allow_network here.
get = http_get if http_get is not None else urllib_get
text = read_http(
source.base_url,
extraction.query,
max_rows=extraction.max_rows,
credential_ref=source.credential_ref,
get=get,
)
body = render_fenced_block(text) # verbatim, NOT table-escaped
row_count = len(text.splitlines())
_LOGGER.info(
"source call: source=%s ingested_at=%s rows=%d", source.id, ingested_at, row_count
)