|
| 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. |
0 commit comments