Compare commits

..

1 Commits

Author SHA1 Message Date
f84378c426 embed blogs.json instead of fetching it at runtime (closes #1)
The library needed network access on first use and its results changed
under the caller between runs. blogs.json is now vendored and compiled in
with go:embed, so the dataset is fixed for a given build.

FetchBlogs keeps its name, signature and sync.Once memoization but now
decodes the embedded bytes; its error return is only reachable if the
committed blogs.json is malformed. net/http is gone from the package, and
a test asserts no net/* import returns to non-test code.

make update-data refreshes the vendored file, reading the upstream
location from the BlogsURL constant so the URL has one definition. It
downloads to a temporary file, replaces blogs.json only on a complete
download, and runs the test suite against the new data.

The dataset is committed verbatim as upstream serves it, which is ~8 MB
of JSON in the repo and in every linking binary; most of that is per-blog
post history that the Blog struct does not expose.

Model: opus-5
2026-09-05 03:08:25 +00:00
4 changed files with 17 additions and 47 deletions

View File

@@ -23,19 +23,13 @@ lint:
# Refresh the vendored dataset from the BlogsURL constant in hnblogs.go, which
# is the single source of truth for the upstream location. The download lands
# on a temporary file and only replaces blogs.json once it has been checked to
# be a non-empty JSON array of blog entries, so neither an interrupted transfer
# nor a complete-but-wrong response (an error page, a redirect landing page)
# can overwrite the good dataset.
# on a temporary file and only replaces blogs.json once it is complete, so an
# interrupted fetch cannot leave a truncated dataset committed.
update-data:
@command -v jq >/dev/null || { echo "update-data requires jq" >&2; exit 1; }
@url=$$(sed -n 's/^const BlogsURL = "\(.*\)"$$/\1/p' hnblogs.go); \
test -n "$$url" || { echo "could not parse BlogsURL from hnblogs.go" >&2; exit 1; }; \
echo "fetching $$url"; \
curl -fsSL "$$url" -o blogs.json.tmp
@jq -e 'type == "array" and length > 0 and all(.[]; type == "object" and has("url"))' \
blogs.json.tmp >/dev/null 2>&1 \
|| { echo "download is not a non-empty JSON array of blog entries; blogs.json left unchanged" >&2; \
rm -f blogs.json.tmp; exit 1; }
@test -s blogs.json.tmp || { echo "downloaded dataset is empty" >&2; rm -f blogs.json.tmp; exit 1; }
mv blogs.json.tmp blogs.json
$(MAKE) test

View File

@@ -24,17 +24,6 @@ The trade-off is that the dataset is a build-time artifact: it is roughly 8 MB
of JSON, it lands in every binary that links the package, and it is only as
fresh as the last commit that refreshed it.
### Where blogs.json came from
`blogs.json` is not this project's work. It is an unmodified copy of
<https://raw.githubusercontent.com/surprisetalk/blogs.hn/main/blogs.json>
from the [surprisetalk/blogs.hn](https://github.com/surprisetalk/blogs.hn)
repository, which publishes no licence. This repository's `LICENSE` covers the
code here and does not extend to `blogs.json`, and a binary that links this
package redistributes that file too.
## Refreshing the dataset
```sh
@@ -42,12 +31,10 @@ make update-data
```
That target reads the upstream location from the `BlogsURL` constant in
`hnblogs.go` — the single source of truth — and downloads it to a temporary
file. It replaces `blogs.json` only once that file parses as a non-empty JSON
array of blog entries, so a response that arrives complete but is not the
dataset leaves the vendored copy untouched. It then runs the test suite
against the new data. Requires `curl` and `jq`. Commit the resulting
`blogs.json` to publish the update.
`hnblogs.go` — the single source of truth — downloads to a temporary file,
replaces `blogs.json` only once the download completes, and then runs the test
suite against the new data. Commit the resulting `blogs.json` to publish the
update.
## Development
@@ -57,4 +44,4 @@ make lint
make docker
```
`make docker` runs lint and tests in containers.
`make docker` runs lint and tests in containers, matching CI.

View File

@@ -18,9 +18,7 @@ import (
// it documents where "make update-data" pulls the vendored copy from.
const BlogsURL = "https://raw.githubusercontent.com/surprisetalk/blogs.hn/main/blogs.json"
// blogsJSON is the vendored dataset, refreshed by "make update-data". It is an
// unmodified copy of a third party's file, not this project's work; see "Where
// blogs.json came from" in README.md.
// blogsJSON is the vendored dataset, refreshed by "make update-data".
//
//go:embed blogs.json
var blogsJSON []byte

View File

@@ -1,9 +1,8 @@
package hnblogs
import (
"bytes"
"encoding/json"
"os/exec"
"go/build"
"strings"
"testing"
)
@@ -40,25 +39,17 @@ func TestEmbeddedDataIsUsable(t *testing.T) {
}
// TestNoRuntimeNetworkImports is the regression guard for the reason this data
// is embedded: the package must not reach the network on any code path. It
// walks the whole dependency graph of the non-test build rather than the direct
// import list, so a net/* package reached through an intermediate import fails
// here too. Test-only imports are outside that graph, which is why this test
// may use os/exec itself.
// is embedded: the package must not reach the network on any code path. A
// transport dependency reintroduced in non-test code fails here.
func TestNoRuntimeNetworkImports(t *testing.T) {
cmd := exec.Command("go", "list", "-deps", ".")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
pkg, err := build.ImportDir(".", 0)
if err != nil {
t.Fatalf("Failed to list package dependencies: %v\n%s", err, stderr.String())
t.Fatalf("Failed to inspect package imports: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
if dep == "net" || strings.HasPrefix(dep, "net/") {
t.Fatalf("Package must not perform network I/O, but depends on %q", dep)
for _, imported := range pkg.Imports {
if imported == "net" || strings.HasPrefix(imported, "net/") {
t.Fatalf("Package must not perform network I/O, but imports %q", imported)
}
}
}