purl 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/SECURITY.md ADDED
@@ -0,0 +1,164 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ We actively support and provide security updates for the following versions:
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | 1.x.x | :white_check_mark: |
10
+ | < 1.0 | :x: |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ The Purl team takes security seriously. If you discover a security vulnerability, please follow these steps:
15
+
16
+ ### 1. Do NOT Create a Public Issue
17
+
18
+ Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
19
+
20
+ ### 2. Report Privately
21
+
22
+ Send a detailed report to **andrew@ecosyste.ms** with:
23
+
24
+ - **Subject**: `[SECURITY] Purl Ruby - [Brief Description]`
25
+ - **Description** of the vulnerability
26
+ - **Steps to reproduce** the issue
27
+ - **Potential impact** assessment
28
+ - **Suggested fix** (if you have one)
29
+ - **Your contact information** for follow-up
30
+
31
+ ### 3. What to Include
32
+
33
+ Please provide as much information as possible:
34
+
35
+ ```
36
+ - Affected versions
37
+ - Attack vectors
38
+ - Proof of concept (if safe to share)
39
+ - Environmental details (Ruby version, OS, etc.)
40
+ - Any relevant configuration details
41
+ ```
42
+
43
+ ## Response Process
44
+
45
+ ### Initial Response
46
+
47
+ - **24-48 hours**: We will acknowledge receipt of your report
48
+ - **Initial assessment**: Within 1 week of acknowledgment
49
+ - **Status updates**: Weekly until resolution
50
+
51
+ ### Investigation
52
+
53
+ We will:
54
+ 1. **Confirm** the vulnerability exists
55
+ 2. **Assess** the severity and impact
56
+ 3. **Develop** a fix and mitigation strategy
57
+ 4. **Test** the fix thoroughly
58
+ 5. **Coordinate** disclosure timeline
59
+
60
+ ### Resolution
61
+
62
+ - **High/Critical**: Immediate fix and release
63
+ - **Medium**: Fix within 30 days
64
+ - **Low**: Fix in next regular release cycle
65
+
66
+ ## Security Considerations
67
+
68
+ ### Input Validation
69
+
70
+ The Purl library processes Package URL strings and performs:
71
+
72
+ - **Scheme validation**: Ensures proper `pkg:` prefix
73
+ - **Component parsing**: Validates type, namespace, name, version
74
+ - **URI encoding**: Handles percent-encoded characters
75
+ - **Qualifier parsing**: Processes key-value parameters
76
+
77
+ ### Potential Risk Areas
78
+
79
+ Areas that warrant security attention:
80
+
81
+ 1. **URL Parsing**: Malformed URLs could cause parsing errors
82
+ 2. **Regular Expressions**: Complex patterns may be vulnerable to ReDoS
83
+ 3. **JSON Processing**: Configuration files require safe parsing
84
+ 4. **Network Requests**: Registry URL generation involves external URLs
85
+
86
+ ### Safe Usage Practices
87
+
88
+ When using Purl in applications:
89
+
90
+ - **Validate input**: Don't trust user-provided PURL strings
91
+ - **Handle errors**: Properly catch and handle parsing exceptions
92
+ - **Sanitize output**: Be careful when displaying parsed components
93
+ - **Rate limiting**: If parsing many PURLs, implement appropriate limits
94
+
95
+ ## Disclosure Policy
96
+
97
+ ### Coordinated Disclosure
98
+
99
+ We follow coordinated disclosure principles:
100
+
101
+ 1. **Private reporting** allows us to fix issues before public disclosure
102
+ 2. **Reasonable timeline** for fixes (typically 90 days maximum)
103
+ 3. **Credit and recognition** for responsible reporters
104
+ 4. **Public disclosure** after fixes are available
105
+
106
+ ### Public Disclosure
107
+
108
+ After a fix is released:
109
+
110
+ 1. **Security advisory** published on GitHub
111
+ 2. **CVE requested** if applicable
112
+ 3. **Release notes** include security information
113
+ 4. **Community notification** through appropriate channels
114
+
115
+ ## Security Updates
116
+
117
+ ### Notification Channels
118
+
119
+ Security updates are announced through:
120
+
121
+ - **GitHub Security Advisories**
122
+ - **RubyGems security alerts**
123
+ - **Release notes and CHANGELOG**
124
+ - **Project README updates**
125
+
126
+ ### Update Recommendations
127
+
128
+ To stay secure:
129
+
130
+ - **Monitor** our security advisories
131
+ - **Update regularly** to the latest version
132
+ - **Review** release notes for security fixes
133
+ - **Subscribe** to GitHub notifications for this repository
134
+
135
+ ## Bug Bounty
136
+
137
+ Currently, we do not offer a formal bug bounty program. However, we deeply appreciate security researchers who help improve the project's security posture.
138
+
139
+ ### Recognition
140
+
141
+ Contributors who responsibly disclose security issues will be:
142
+
143
+ - **Credited** in security advisories (with permission)
144
+ - **Mentioned** in release notes
145
+ - **Recognized** in project documentation
146
+ - **Thanked** publicly (unless anonymity is requested)
147
+
148
+ ## Contact Information
149
+
150
+ **Security Contact**: andrew@ecosyste.ms
151
+
152
+ **PGP Key**: Available upon request for encrypted communications
153
+
154
+ **Response Time**: We aim to acknowledge security reports within 24-48 hours
155
+
156
+ ## Additional Resources
157
+
158
+ - [PURL Specification Security Considerations](https://github.com/package-url/purl-spec)
159
+ - [Ruby Security Best Practices](https://guides.rubyonrails.org/security.html)
160
+ - [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/)
161
+
162
+ ---
163
+
164
+ Thank you for helping keep Purl and its users safe!
@@ -210,10 +210,18 @@ module Purl
210
210
  end
211
211
 
212
212
  def deconstruct_keys(keys)
213
- to_h.slice(*keys) if keys
213
+ return to_h.slice(*keys) if keys
214
214
  to_h
215
215
  end
216
216
 
217
+ # Create a new PackageURL with modified attributes
218
+ # Usage: new_purl = purl.with(version: "2.0.0", qualifiers: {"arch" => "x64"})
219
+ def with(**changes)
220
+ current_attrs = to_h
221
+ new_attrs = current_attrs.merge(changes)
222
+ self.class.new(**new_attrs)
223
+ end
224
+
217
225
  private
218
226
 
219
227
  def validate_and_normalize_type(type)
@@ -24,29 +24,85 @@ module Purl
24
24
  end
25
25
 
26
26
  def self.build_pattern_config(type, config)
27
+ # Get the default registry for this type from parent config
28
+ type_config = load_types_config["types"][type]
29
+ default_registry = type_config["default_registry"]
30
+
31
+ # Build full URLs from templates if we have a default registry
32
+ route_patterns = []
33
+ if default_registry
34
+ # Add all template variations
35
+ if config["path_template"]
36
+ route_patterns << default_registry + config["path_template"]
37
+ end
38
+ if config["namespace_path_template"]
39
+ route_patterns << default_registry + config["namespace_path_template"]
40
+ end
41
+ if config["version_path_template"]
42
+ route_patterns << default_registry + config["version_path_template"]
43
+ end
44
+ if config["namespace_version_path_template"]
45
+ route_patterns << default_registry + config["namespace_version_path_template"]
46
+ end
47
+ end
48
+ # Fall back to legacy route_patterns if available
49
+ route_patterns = config["route_patterns"] if route_patterns.empty? && config["route_patterns"]
50
+
51
+ # Build reverse regex from template or use legacy format
52
+ reverse_regex = nil
53
+ if config["reverse_regex"]
54
+ if config["reverse_regex"].start_with?("/") && default_registry
55
+ # Domain-agnostic pattern - combine with default registry domain
56
+ domain_pattern = default_registry.sub(/^https?:\/\//, '').gsub('.', '\\.')
57
+ reverse_regex = Regexp.new("^https?://#{domain_pattern}" + config["reverse_regex"])
58
+ else
59
+ # Legacy full pattern
60
+ reverse_regex = Regexp.new(config["reverse_regex"])
61
+ end
62
+ end
63
+
27
64
  {
28
- base_url: config["base_url"],
29
- route_patterns: config["route_patterns"] || [],
30
- reverse_regex: config["reverse_regex"] ? Regexp.new(config["reverse_regex"]) : nil,
31
- pattern: build_generation_lambda(type, config),
32
- reverse_parser: config["reverse_regex"] ? build_reverse_parser(type, config) : nil
65
+ base_url: config["base_url"] || (default_registry ? default_registry + config["path_template"]&.split('/:').first : nil),
66
+ route_patterns: route_patterns,
67
+ reverse_regex: reverse_regex,
68
+ pattern: build_generation_lambda(type, config, default_registry),
69
+ reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil
33
70
  }
34
71
  end
35
72
 
36
- def self.build_generation_lambda(type, config)
73
+ # Load types config (needed for accessing default_registry)
74
+ def self.load_types_config
75
+ @types_config ||= begin
76
+ config_path = File.join(__dir__, "..", "..", "purl-types.json")
77
+ require "json"
78
+ JSON.parse(File.read(config_path))
79
+ end
80
+ end
81
+
82
+ def self.build_generation_lambda(type, config, default_registry = nil)
83
+ # Use base_url from config, or build from default_registry + path_template base
84
+ if config["base_url"]
85
+ base_url = config["base_url"]
86
+ elsif default_registry && config["path_template"]
87
+ # Extract the base path from the template (everything before first :parameter)
88
+ base_path = config["path_template"].split('/:').first
89
+ base_url = default_registry + base_path
90
+ else
91
+ return nil
92
+ end
37
93
  case type
38
94
  when "npm"
39
95
  ->(purl) do
40
96
  if purl.namespace
41
- "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
97
+ "#{base_url}/#{purl.namespace}/#{purl.name}"
42
98
  else
43
- "#{config["base_url"]}/#{purl.name}"
99
+ "#{base_url}/#{purl.name}"
44
100
  end
45
101
  end
46
102
  when "composer", "maven", "swift"
47
103
  ->(purl) do
48
104
  if purl.namespace
49
- "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
105
+ "#{base_url}/#{purl.namespace}/#{purl.name}"
50
106
  else
51
107
  raise MissingRegistryInfoError.new(
52
108
  "#{type.capitalize} packages require a namespace",
@@ -58,42 +114,42 @@ module Purl
58
114
  when "golang"
59
115
  ->(purl) do
60
116
  if purl.namespace
61
- "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
117
+ "#{base_url}/#{purl.namespace}/#{purl.name}"
62
118
  else
63
- "#{config["base_url"]}/#{purl.name}"
119
+ "#{base_url}/#{purl.name}"
64
120
  end
65
121
  end
66
122
  when "pypi"
67
- ->(purl) { "#{config["base_url"]}/#{purl.name}/" }
123
+ ->(purl) { "#{base_url}/#{purl.name}/" }
68
124
  when "hackage"
69
125
  ->(purl) do
70
126
  if purl.version
71
- "#{config["base_url"]}/#{purl.name}-#{purl.version}"
127
+ "#{base_url}/#{purl.name}-#{purl.version}"
72
128
  else
73
- "#{config["base_url"]}/#{purl.name}"
129
+ "#{base_url}/#{purl.name}"
74
130
  end
75
131
  end
76
132
  when "deno"
77
133
  ->(purl) do
78
134
  if purl.version
79
- "#{config["base_url"]}/#{purl.name}@#{purl.version}"
135
+ "#{base_url}/#{purl.name}@#{purl.version}"
80
136
  else
81
- "#{config["base_url"]}/#{purl.name}"
137
+ "#{base_url}/#{purl.name}"
82
138
  end
83
139
  end
84
140
  when "clojars"
85
141
  ->(purl) do
86
142
  if purl.namespace
87
- "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
143
+ "#{base_url}/#{purl.namespace}/#{purl.name}"
88
144
  else
89
- "#{config["base_url"]}/#{purl.name}"
145
+ "#{base_url}/#{purl.name}"
90
146
  end
91
147
  end
92
148
  when "elm"
93
149
  ->(purl) do
94
150
  if purl.namespace
95
151
  version = purl.version || "latest"
96
- "#{config["base_url"]}/#{purl.namespace}/#{purl.name}/#{version}"
152
+ "#{base_url}/#{purl.namespace}/#{purl.name}/#{version}"
97
153
  else
98
154
  raise MissingRegistryInfoError.new(
99
155
  "Elm packages require a namespace",
@@ -103,7 +159,7 @@ module Purl
103
159
  end
104
160
  end
105
161
  else
106
- ->(purl) { "#{config["base_url"]}/#{purl.name}" }
162
+ ->(purl) { "#{base_url}/#{purl.name}" }
107
163
  end
108
164
  end
109
165
 
@@ -177,6 +233,67 @@ module Purl
177
233
  version = match[3] unless match[3] == "latest"
178
234
  { type: type, namespace: namespace, name: name, version: version }
179
235
  end
236
+ when "cocoapods"
237
+ ->(match) do
238
+ name = match[1]
239
+ { type: type, namespace: nil, name: name, version: nil }
240
+ end
241
+ when "composer"
242
+ ->(match) do
243
+ namespace = match[1]
244
+ name = match[2]
245
+ { type: type, namespace: namespace, name: name, version: nil }
246
+ end
247
+ when "conda"
248
+ ->(match) do
249
+ name = match[1]
250
+ { type: type, namespace: nil, name: name, version: nil }
251
+ end
252
+ when "cpan"
253
+ ->(match) do
254
+ name = match[1]
255
+ { type: type, namespace: nil, name: name, version: nil }
256
+ end
257
+ when "hex"
258
+ ->(match) do
259
+ name = match[1]
260
+ { type: type, namespace: nil, name: name, version: nil }
261
+ end
262
+ when "nuget"
263
+ ->(match) do
264
+ name = match[1]
265
+ version = match[2] # from /version pattern
266
+ { type: type, namespace: nil, name: name, version: version }
267
+ end
268
+ when "pub"
269
+ ->(match) do
270
+ name = match[1]
271
+ { type: type, namespace: nil, name: name, version: nil }
272
+ end
273
+ when "swift"
274
+ ->(match) do
275
+ namespace = match[1]
276
+ name = match[2]
277
+ { type: type, namespace: namespace, name: name, version: nil }
278
+ end
279
+ when "bioconductor"
280
+ ->(match) do
281
+ name = match[1]
282
+ { type: type, namespace: nil, name: name, version: nil }
283
+ end
284
+ when "clojars"
285
+ ->(match) do
286
+ if match[1] && match[2]
287
+ # Has namespace: clojars.org/namespace/name
288
+ namespace = match[1]
289
+ name = match[2]
290
+ else
291
+ # No namespace: clojars.org/name
292
+ namespace = nil
293
+ name = match[1] || match[2]
294
+ end
295
+ { type: type, namespace: namespace, name: name, version: nil }
296
+ end
180
297
  else
181
298
  ->(match) do
182
299
  { type: type, namespace: nil, name: match[1], version: nil }
@@ -187,8 +304,8 @@ module Purl
187
304
  # Registry patterns loaded from JSON configuration
188
305
  REGISTRY_PATTERNS = load_registry_patterns.freeze
189
306
 
190
- def self.generate(purl)
191
- new(purl).generate
307
+ def self.generate(purl, base_url: nil)
308
+ new(purl).generate(base_url: base_url)
192
309
  end
193
310
 
194
311
  def self.supported_types
@@ -199,9 +316,49 @@ module Purl
199
316
  REGISTRY_PATTERNS.key?(type.to_s.downcase)
200
317
  end
201
318
 
202
- def self.from_url(registry_url)
203
- # Try to parse the registry URL back into a PURL
204
- REGISTRY_PATTERNS.each do |type, config|
319
+ def self.from_url(registry_url, type: nil)
320
+ # If type is specified, try that specific type first with domain-agnostic parsing
321
+ if type
322
+ normalized_type = type.to_s.downcase
323
+ config = REGISTRY_PATTERNS[normalized_type]
324
+
325
+ if config && config[:reverse_regex] && config[:reverse_parser]
326
+ # Create a domain-agnostic version of the regex by replacing the base domain
327
+ original_regex = config[:reverse_regex].source
328
+
329
+ # For simplified JSON patterns that start with /, create domain-agnostic regex
330
+ domain_agnostic_regex = nil
331
+ if original_regex.start_with?("/")
332
+ # Domain-agnostic pattern - match any domain with this path
333
+ domain_agnostic_regex = Regexp.new("^https?://[^/]+" + original_regex)
334
+ else
335
+ # Legacy full regex pattern
336
+ if original_regex =~ /\^https?:\/\/[^\/]+(.+)$/
337
+ path_pattern = $1
338
+ # Create domain-agnostic regex that matches any domain with the same path structure
339
+ domain_agnostic_regex = Regexp.new("^https?://[^/]+" + path_pattern)
340
+ end
341
+ end
342
+
343
+ if domain_agnostic_regex
344
+ match = registry_url.match(domain_agnostic_regex)
345
+ if match
346
+ parsed_data = config[:reverse_parser].call(match)
347
+ return PackageURL.new(
348
+ type: parsed_data[:type],
349
+ namespace: parsed_data[:namespace],
350
+ name: parsed_data[:name],
351
+ version: parsed_data[:version]
352
+ )
353
+ end
354
+ end
355
+ end
356
+
357
+ # If specified type didn't work, fall through to normal domain-matching logic
358
+ end
359
+
360
+ # Try to parse the registry URL back into a PURL using domain matching
361
+ REGISTRY_PATTERNS.each do |registry_type, config|
205
362
  next unless config[:reverse_regex] && config[:reverse_parser]
206
363
 
207
364
  match = registry_url.match(config[:reverse_regex])
@@ -216,8 +373,15 @@ module Purl
216
373
  end
217
374
  end
218
375
 
376
+ error_message = if type
377
+ "Unable to parse registry URL: #{registry_url} as type '#{type}'. " +
378
+ "URL structure doesn't match expected pattern for this type."
379
+ else
380
+ "Unable to parse registry URL: #{registry_url}. No matching pattern found."
381
+ end
382
+
219
383
  raise UnsupportedTypeError.new(
220
- "Unable to parse registry URL: #{registry_url}. No matching pattern found.",
384
+ error_message,
221
385
  supported_types: REGISTRY_PATTERNS.keys.select { |k| REGISTRY_PATTERNS[k][:reverse_regex] }
222
386
  )
223
387
  end
@@ -247,7 +411,7 @@ module Purl
247
411
  @purl = purl
248
412
  end
249
413
 
250
- def generate
414
+ def generate(base_url: nil)
251
415
  pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
252
416
 
253
417
  unless pattern_config
@@ -259,7 +423,13 @@ module Purl
259
423
  end
260
424
 
261
425
  begin
262
- pattern_config[:pattern].call(@purl)
426
+ if base_url
427
+ # Use custom base URL with the same URL structure
428
+ generate_with_custom_base_url(base_url, pattern_config)
429
+ else
430
+ # Use default base URL
431
+ pattern_config[:pattern].call(@purl)
432
+ end
263
433
  rescue MissingRegistryInfoError
264
434
  raise
265
435
  rescue => e
@@ -267,23 +437,87 @@ module Purl
267
437
  end
268
438
  end
269
439
 
270
- def generate_with_version
271
- base_url = generate
440
+ def generate_with_version(base_url: nil)
441
+ registry_url = generate(base_url: base_url)
272
442
 
273
443
  case @purl.type.downcase
274
444
  when "npm"
275
- @purl.version ? "#{base_url}/v/#{@purl.version}" : base_url
445
+ @purl.version ? "#{registry_url}/v/#{@purl.version}" : registry_url
276
446
  when "pypi"
277
- @purl.version ? "#{base_url}#{@purl.version}/" : base_url
447
+ @purl.version ? "#{registry_url}#{@purl.version}/" : registry_url
278
448
  when "gem"
279
- @purl.version ? "#{base_url}/versions/#{@purl.version}" : base_url
449
+ @purl.version ? "#{registry_url}/versions/#{@purl.version}" : registry_url
280
450
  when "maven"
281
- @purl.version ? "#{base_url}/#{@purl.version}" : base_url
451
+ @purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
282
452
  when "nuget"
283
- @purl.version ? "#{base_url}/#{@purl.version}" : base_url
453
+ @purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
284
454
  else
285
455
  # For other types, just return the base URL since version-specific URLs vary
286
- base_url
456
+ registry_url
457
+ end
458
+ end
459
+
460
+ private
461
+
462
+ def generate_with_custom_base_url(custom_base_url, pattern_config)
463
+
464
+ # Replace the base URL in the pattern lambda
465
+ case @purl.type.downcase
466
+ when "npm"
467
+ if @purl.namespace
468
+ "#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
469
+ else
470
+ "#{custom_base_url}/#{@purl.name}"
471
+ end
472
+ when "composer", "maven", "swift"
473
+ if @purl.namespace
474
+ "#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
475
+ else
476
+ raise MissingRegistryInfoError.new(
477
+ "#{@purl.type.capitalize} packages require a namespace",
478
+ type: @purl.type,
479
+ missing: "namespace"
480
+ )
481
+ end
482
+ when "golang"
483
+ if @purl.namespace
484
+ "#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
485
+ else
486
+ "#{custom_base_url}/#{@purl.name}"
487
+ end
488
+ when "pypi"
489
+ "#{custom_base_url}/#{@purl.name}/"
490
+ when "hackage"
491
+ if @purl.version
492
+ "#{custom_base_url}/#{@purl.name}-#{@purl.version}"
493
+ else
494
+ "#{custom_base_url}/#{@purl.name}"
495
+ end
496
+ when "deno"
497
+ if @purl.version
498
+ "#{custom_base_url}/#{@purl.name}@#{@purl.version}"
499
+ else
500
+ "#{custom_base_url}/#{@purl.name}"
501
+ end
502
+ when "clojars"
503
+ if @purl.namespace
504
+ "#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
505
+ else
506
+ "#{custom_base_url}/#{@purl.name}"
507
+ end
508
+ when "elm"
509
+ if @purl.namespace
510
+ version = @purl.version || "latest"
511
+ "#{custom_base_url}/#{@purl.namespace}/#{@purl.name}/#{version}"
512
+ else
513
+ raise MissingRegistryInfoError.new(
514
+ "Elm packages require a namespace",
515
+ type: @purl.type,
516
+ missing: "namespace"
517
+ )
518
+ end
519
+ else
520
+ "#{custom_base_url}/#{@purl.name}"
287
521
  end
288
522
  end
289
523
 
@@ -294,12 +528,12 @@ module Purl
294
528
 
295
529
  # Add registry URL generation methods to PackageURL
296
530
  class PackageURL
297
- def registry_url
298
- RegistryURL.generate(self)
531
+ def registry_url(base_url: nil)
532
+ RegistryURL.generate(self, base_url: base_url)
299
533
  end
300
534
 
301
- def registry_url_with_version
302
- RegistryURL.new(self).generate_with_version
535
+ def registry_url_with_version(base_url: nil)
536
+ RegistryURL.new(self).generate_with_version(base_url: base_url)
303
537
  end
304
538
 
305
539
  def supports_registry_url?
data/lib/purl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purl
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/purl.rb CHANGED
@@ -26,8 +26,10 @@ module Purl
26
26
  end
27
27
 
28
28
  # Convenience method for parsing registry URLs back to PURLs
29
- def self.from_registry_url(registry_url)
30
- RegistryURL.from_url(registry_url)
29
+ # @param registry_url [String] The registry URL to parse
30
+ # @param type [String, Symbol, nil] Optional type hint for custom domains
31
+ def self.from_registry_url(registry_url, type: nil)
32
+ RegistryURL.from_url(registry_url, type: type)
31
33
  end
32
34
 
33
35
  # Returns all known PURL types
@@ -56,6 +58,9 @@ module Purl
56
58
  {
57
59
  type: normalized_type,
58
60
  known: known_type?(normalized_type),
61
+ description: type_description(normalized_type),
62
+ default_registry: default_registry(normalized_type),
63
+ examples: type_examples(normalized_type),
59
64
  registry_url_generation: RegistryURL.supports?(normalized_type),
60
65
  reverse_parsing: RegistryURL.supported_reverse_types.include?(normalized_type),
61
66
  route_patterns: RegistryURL.route_patterns_for(normalized_type)
@@ -95,6 +100,14 @@ module Purl
95
100
  config ? config["description"] : nil
96
101
  end
97
102
 
103
+ # Get examples for a type
104
+ def self.type_examples(type)
105
+ config = type_config(type)
106
+ return [] unless config
107
+
108
+ config["examples"] || []
109
+ end
110
+
98
111
  # Get registry configuration for a type
99
112
  def self.registry_config(type)
100
113
  config = type_config(type)
@@ -103,6 +116,14 @@ module Purl
103
116
  config["registry_config"]
104
117
  end
105
118
 
119
+ # Get default registry URL for a type
120
+ def self.default_registry(type)
121
+ config = type_config(type)
122
+ return nil unless config
123
+
124
+ config["default_registry"]
125
+ end
126
+
106
127
  # Get metadata about the types configuration
107
128
  def self.types_config_metadata
108
129
  config = load_types_config
@@ -112,7 +133,8 @@ module Purl
112
133
  source: config["source"],
113
134
  last_updated: config["last_updated"],
114
135
  total_types: config["types"].keys.length,
115
- registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length
136
+ registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length,
137
+ types_with_default_registry: config["types"].select { |_, v| v["default_registry"] }.keys.length
116
138
  }
117
139
  end
118
140
  end