Skip to content

Commit dbe8800

Browse files
committed
docs(builders): add PurlBuilder fluent-API walkthrough
Covers when to use the builder vs the constructor, every setter method, all 26 per-ecosystem static factories, qualifier conventions, worked examples (package.json entry, download URL qualifier, in-place field tweak, many qualifiers), validation timing (fail-late-at-build), and the ESM/CJS instanceof footgun. Junior-dev level: decision table up front for "which construction style fits my situation", every code sample is self-contained and shows both input and canonical output.
1 parent 804619f commit dbe8800

2 files changed

Lines changed: 302 additions & 0 deletions

File tree

docs/builders.md

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# Builders
2+
3+
The `PurlBuilder` fluent API — construct a `PackageURL` step by
4+
step, with per-field setters and per-ecosystem factories. Read
5+
this when you want to build a PURL from computed values rather
6+
than handing every argument to a constructor all at once.
7+
8+
## Who this is for
9+
10+
Callers with a runtime shape that doesn't fit a single constructor
11+
call — you have a loop, a conditional, or a pipeline where each
12+
piece of the PURL lands at a different step. Also contributors
13+
adding a new ecosystem factory.
14+
15+
## When to use what
16+
17+
| You have… | Use |
18+
|---|---|
19+
| All six pieces in hand at once | `new PackageURL(type, ns, name, version, qualifiers, subpath)` — positional, fastest. |
20+
| A loop / conditional that sets fields over time | `PurlBuilder` — method chaining, field-by-field. |
21+
| An existing PackageURL and want to tweak one field | `PurlBuilder.from(existing).name('new').build()` — creates a fresh instance since PackageURL is frozen. |
22+
| A string from the wire | `new PackageURL(str)` or `PackageURL.fromStringResult(str)` — see `docs/hardening.md`. |
23+
24+
The builder is not faster than the constructor; it is easier to
25+
read when construction is spread across code.
26+
27+
## The fluent API at a glance
28+
29+
```typescript
30+
import { PurlBuilder } from '@socketregistry/packageurl-js'
31+
32+
const purl = PurlBuilder.create()
33+
.type('npm')
34+
.namespace('@scope')
35+
.name('left-pad')
36+
.version('1.3.0')
37+
.qualifier('extension', 'tgz')
38+
.subpath('lib')
39+
.build()
40+
41+
purl.toString()
42+
// 'pkg:npm/%40scope/left-pad@1.3.0?extension=tgz#lib'
43+
```
44+
45+
Every setter returns `this` so calls chain. `build()` returns the
46+
frozen `PackageURL` and validates — if a required field is missing
47+
or a value fails its component's validator, `build()` throws.
48+
49+
## The setters
50+
51+
| Method | Sets | Notes |
52+
|---|---|---|
53+
| `.type(str)` | The package type (`npm`, `pypi`, `maven`, …). Required. | Lowercased. Must match a registered `PurlType`. |
54+
| `.namespace(str)` | Namespace / scope / group (e.g. `@scope` for npm, `org.acme` for maven). Optional. | Normalization depends on type. npm lowercases; maven preserves case. |
55+
| `.name(str)` | Package name. Required. | Same as namespace — normalization per type. |
56+
| `.version(str)` | Version string. Optional. | Free-form; validated for injection chars but not semver-shape (ecosystems disagree). |
57+
| `.qualifier(key, value)` | One key-value qualifier. Add many by chaining multiple calls. | See the known-qualifier list below. |
58+
| `.qualifiers(obj)` | Set all qualifiers at once from an object. | Replaces any previously-set qualifiers. |
59+
| `.subpath(str)` | Subpath within the package (e.g. `lib/utils`). Optional. | Leading/trailing slashes are stripped. |
60+
| `.build()` | Finalize. | Throws on invalid. |
61+
62+
## The per-ecosystem factories
63+
64+
For common ecosystems, the builder has a static shortcut that pre-
65+
sets `.type()`:
66+
67+
```typescript
68+
// These two are equivalent:
69+
PurlBuilder.create().type('npm').name('lodash').version('4.17.21').build()
70+
PurlBuilder.npm().name('lodash').version('4.17.21').build()
71+
```
72+
73+
Available factories:
74+
75+
| Factory | Ecosystem | Preset type |
76+
|---|---|---|
77+
| `PurlBuilder.bitbucket()` | Bitbucket repos | `bitbucket` |
78+
| `PurlBuilder.cargo()` | Rust crates | `cargo` |
79+
| `PurlBuilder.cocoapods()` | iOS/macOS pods | `cocoapods` |
80+
| `PurlBuilder.composer()` | PHP packages | `composer` |
81+
| `PurlBuilder.conan()` | C/C++ (Conan Center) | `conan` |
82+
| `PurlBuilder.conda()` | Conda packages | `conda` |
83+
| `PurlBuilder.cran()` | R packages | `cran` |
84+
| `PurlBuilder.deb()` | Debian packages | `deb` |
85+
| `PurlBuilder.docker()` | Docker images | `docker` |
86+
| `PurlBuilder.gem()` | Ruby gems | `gem` |
87+
| `PurlBuilder.github()` | GitHub repos | `github` |
88+
| `PurlBuilder.gitlab()` | GitLab repos | `gitlab` |
89+
| `PurlBuilder.golang()` | Go modules | `golang` |
90+
| `PurlBuilder.hackage()` | Haskell packages | `hackage` |
91+
| `PurlBuilder.hex()` | Elixir/Erlang packages | `hex` |
92+
| `PurlBuilder.huggingface()` | Hugging Face models | `huggingface` |
93+
| `PurlBuilder.luarocks()` | Lua packages | `luarocks` |
94+
| `PurlBuilder.maven()` | Maven Central | `maven` |
95+
| `PurlBuilder.npm()` | npm packages | `npm` |
96+
| `PurlBuilder.nuget()` | .NET packages | `nuget` |
97+
| `PurlBuilder.oci()` | OCI containers | `oci` |
98+
| `PurlBuilder.pub()` | Dart/Flutter | `pub` |
99+
| `PurlBuilder.pypi()` | Python packages | `pypi` |
100+
| `PurlBuilder.rpm()` | RPM packages | `rpm` |
101+
| `PurlBuilder.swift()` | Swift packages | `swift` |
102+
103+
Generic entry points:
104+
105+
- `PurlBuilder.create()` — no type preset. You must call `.type()`
106+
before `.build()`.
107+
- `PurlBuilder.from(existing: PackageURL)` — seeds every field from
108+
an existing `PackageURL`. Useful for "take this PURL but change
109+
one field."
110+
111+
## Known qualifier keys
112+
113+
Qualifiers are an open key-value space, but the PURL spec (and
114+
downstream tooling) standardizes a few:
115+
116+
| Qualifier | Meaning |
117+
|---|---|
118+
| `checksum` | Digest of the artifact (e.g. `sha256:abc…`). |
119+
| `download_url` | Direct URL to download the artifact. |
120+
| `file_name` | Filename of the distributed artifact (e.g. `tar.gz`, `whl`). |
121+
| `repository_url` | URL of the source repository. |
122+
| `vcs_url` | VCS (git/hg) URL, including commit reference. |
123+
| `vers` | A VERS range (see `docs/vers.md`) constraining the version. |
124+
125+
The library knows these keys and normalizes their values; custom
126+
keys pass through untouched. See
127+
`src/purl-qualifier-names.ts` for the canonical list.
128+
129+
## Worked examples
130+
131+
### Build from a package.json entry
132+
133+
```typescript
134+
function purlFromPackageJson(name: string, version: string): PackageURL {
135+
const builder = PurlBuilder.npm().version(version)
136+
137+
// npm scoped packages: '@scope/pkg' → namespace '@scope', name 'pkg'
138+
if (name.startsWith('@')) {
139+
const [scope, pkg] = name.split('/')
140+
builder.namespace(scope).name(pkg)
141+
} else {
142+
builder.name(name)
143+
}
144+
145+
return builder.build()
146+
}
147+
148+
purlFromPackageJson('lodash', '4.17.21').toString()
149+
// 'pkg:npm/lodash@4.17.21'
150+
151+
purlFromPackageJson('@scope/pkg', '1.0.0').toString()
152+
// 'pkg:npm/%40scope/pkg@1.0.0'
153+
```
154+
155+
### Build with a download URL qualifier
156+
157+
```typescript
158+
const purl = PurlBuilder.pypi()
159+
.name('requests')
160+
.version('2.31.0')
161+
.qualifier('extension', 'tar.gz')
162+
.qualifier('download_url', 'https://files.pythonhosted.org/…/requests-2.31.0.tar.gz')
163+
.build()
164+
165+
purl.toString()
166+
// 'pkg:pypi/requests@2.31.0?download_url=…&extension=tar.gz'
167+
```
168+
169+
Note that qualifiers are alphabetized in the canonical output.
170+
171+
### Tweak one field on an existing PURL
172+
173+
```typescript
174+
const original = new PackageURL('npm', undefined, 'lodash', '4.17.20')
175+
const updated = PurlBuilder.from(original).version('4.17.21').build()
176+
177+
original.toString() // 'pkg:npm/lodash@4.17.20' (unchanged; frozen)
178+
updated.toString() // 'pkg:npm/lodash@4.17.21'
179+
```
180+
181+
`PurlBuilder.from()` is the only sanctioned way to produce a
182+
modified copy. Direct mutation is impossible by design (see
183+
`docs/hardening.md`).
184+
185+
### Chain many qualifiers
186+
187+
```typescript
188+
PurlBuilder.maven()
189+
.namespace('org.apache.logging.log4j')
190+
.name('log4j-core')
191+
.version('2.17.1')
192+
.qualifier('classifier', 'sources')
193+
.qualifier('extension', 'jar')
194+
.qualifier('type', 'sources')
195+
.qualifier('repository_url', 'https://repo.maven.apache.org/maven2')
196+
.build()
197+
```
198+
199+
Alternative using `.qualifiers(obj)`:
200+
201+
```typescript
202+
PurlBuilder.maven()
203+
.namespace('org.apache.logging.log4j')
204+
.name('log4j-core')
205+
.version('2.17.1')
206+
.qualifiers({
207+
classifier: 'sources',
208+
extension: 'jar',
209+
type: 'sources',
210+
repository_url: 'https://repo.maven.apache.org/maven2',
211+
})
212+
.build()
213+
```
214+
215+
Both produce the same PURL. Use `.qualifier()` when adding one at
216+
a time inside a loop; use `.qualifiers()` when you have the whole
217+
object already.
218+
219+
## Validation timing
220+
221+
The builder does **not** validate as you set — so:
222+
223+
```typescript
224+
PurlBuilder.create()
225+
.type('npm')
226+
.name('') // empty name — won't error here
227+
```
228+
229+
Validation runs when you call `.build()`. That call constructs a
230+
new `PackageURL`, which invokes the per-component validators. A
231+
failure at `.build()` throws with a message pointing at the
232+
offending field.
233+
234+
This "fail late" design lets you construct a builder in one place
235+
and pass it around (e.g. to helper functions that set more fields)
236+
without each mutation being a potential throw site. If you want
237+
"fail early," prefer the constructor and check the throw at a
238+
single site.
239+
240+
## The ESM/CJS `instanceof` footgun
241+
242+
`PurlBuilder` internally imports `PackageURL` via CommonJS
243+
`require()`. If your code imports `PackageURL` via ESM `import`,
244+
Node wraps the two imports into different objects, and
245+
`builtPurl instanceof PackageURL` returns `false` even though the
246+
structure is correct.
247+
248+
Workaround:
249+
250+
```typescript
251+
// Bad:
252+
const ok = purl instanceof PackageURL
253+
254+
// Good:
255+
const ok = purl && purl.constructor.name === 'PackageURL'
256+
257+
// Also good — use a duck-type check on the fields you care about:
258+
const ok = typeof purl === 'object' && typeof purl.toString === 'function'
259+
```
260+
261+
This limitation is a Node ESM/CJS interop artifact, not a library
262+
bug. Affects only `instanceof`, not any actual functionality.
263+
264+
## Adding a new ecosystem factory
265+
266+
If you implement a new `PurlType` handler under
267+
`src/purl-types/<name>.ts`, add a matching `PurlBuilder.<name>()`
268+
factory:
269+
270+
```typescript
271+
static <name>(): PurlBuilder {
272+
return new PurlBuilder().type('<name>')
273+
}
274+
```
275+
276+
Conventions:
277+
278+
- Method name: ecosystem name, lowercase.
279+
- Body: `new PurlBuilder().type('<name>')`. No other presets.
280+
- Doc comment: one-line description matching the
281+
per-ecosystem-factory table above.
282+
- Alphabetical order in the class.
283+
284+
## Further reading
285+
286+
- [`docs/architecture.md`](./architecture.md) — where the builder
287+
sits in the module map.
288+
- [`docs/converters.md`](./converters.md) — builder's cousin for
289+
URL ↔ PURL round-trips.
290+
- [`docs/hardening.md`](./hardening.md) — why built instances are
291+
frozen.
292+
- [`docs/api.md`](./api.md) — full API reference.
293+
- [`src/package-url-builder.ts`](../src/package-url-builder.ts)
294+
the implementation.
295+
- [`src/purl-qualifier-names.ts`](../src/purl-qualifier-names.ts)
296+
canonical list of known qualifier keys.

tour.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,12 @@
173173
"source": "docs/architecture.md",
174174
"summary": "Module map, data flow, and the key abstractions that keep the library small."
175175
},
176+
{
177+
"filename": "builders",
178+
"title": "Builders",
179+
"source": "docs/builders.md",
180+
"summary": "The PurlBuilder fluent API — construct a PackageURL step by step with per-field setters and per-ecosystem factories."
181+
},
176182
{
177183
"filename": "hardening",
178184
"title": "Hardening",

0 commit comments

Comments
 (0)