purl 0.1.0 → 1.0.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.
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Purl
4
+ class RegistryURL
5
+ # Load registry patterns from JSON configuration
6
+ def self.load_registry_patterns
7
+ @registry_patterns ||= begin
8
+ # Load JSON config directly to avoid circular dependency
9
+ config_path = File.join(__dir__, "..", "..", "purl-types.json")
10
+ require "json"
11
+ config = JSON.parse(File.read(config_path))
12
+ patterns = {}
13
+
14
+ config["types"].each do |type, type_config|
15
+ # Only process types that have registry_config
16
+ next unless type_config["registry_config"]
17
+
18
+ registry_config = type_config["registry_config"]
19
+ patterns[type] = build_pattern_config(type, registry_config)
20
+ end
21
+
22
+ patterns
23
+ end
24
+ end
25
+
26
+ def self.build_pattern_config(type, config)
27
+ {
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
33
+ }
34
+ end
35
+
36
+ def self.build_generation_lambda(type, config)
37
+ case type
38
+ when "npm"
39
+ ->(purl) do
40
+ if purl.namespace
41
+ "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
42
+ else
43
+ "#{config["base_url"]}/#{purl.name}"
44
+ end
45
+ end
46
+ when "composer", "maven", "swift"
47
+ ->(purl) do
48
+ if purl.namespace
49
+ "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
50
+ else
51
+ raise MissingRegistryInfoError.new(
52
+ "#{type.capitalize} packages require a namespace",
53
+ type: purl.type,
54
+ missing: "namespace"
55
+ )
56
+ end
57
+ end
58
+ when "golang"
59
+ ->(purl) do
60
+ if purl.namespace
61
+ "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
62
+ else
63
+ "#{config["base_url"]}/#{purl.name}"
64
+ end
65
+ end
66
+ when "pypi"
67
+ ->(purl) { "#{config["base_url"]}/#{purl.name}/" }
68
+ when "hackage"
69
+ ->(purl) do
70
+ if purl.version
71
+ "#{config["base_url"]}/#{purl.name}-#{purl.version}"
72
+ else
73
+ "#{config["base_url"]}/#{purl.name}"
74
+ end
75
+ end
76
+ when "deno"
77
+ ->(purl) do
78
+ if purl.version
79
+ "#{config["base_url"]}/#{purl.name}@#{purl.version}"
80
+ else
81
+ "#{config["base_url"]}/#{purl.name}"
82
+ end
83
+ end
84
+ when "clojars"
85
+ ->(purl) do
86
+ if purl.namespace
87
+ "#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
88
+ else
89
+ "#{config["base_url"]}/#{purl.name}"
90
+ end
91
+ end
92
+ when "elm"
93
+ ->(purl) do
94
+ if purl.namespace
95
+ version = purl.version || "latest"
96
+ "#{config["base_url"]}/#{purl.namespace}/#{purl.name}/#{version}"
97
+ else
98
+ raise MissingRegistryInfoError.new(
99
+ "Elm packages require a namespace",
100
+ type: purl.type,
101
+ missing: "namespace"
102
+ )
103
+ end
104
+ end
105
+ else
106
+ ->(purl) { "#{config["base_url"]}/#{purl.name}" }
107
+ end
108
+ end
109
+
110
+ def self.build_reverse_parser(type, config)
111
+ case type
112
+ when "npm"
113
+ ->(match) do
114
+ namespace = match[1] # @scope or nil
115
+ name = match[2]
116
+ version = match[3] # from /v/version or nil
117
+ { type: type, namespace: namespace, name: name, version: version }
118
+ end
119
+ when "gem"
120
+ ->(match) do
121
+ name = match[1]
122
+ version = match[2] # from /versions/version or nil
123
+ { type: type, namespace: nil, name: name, version: version }
124
+ end
125
+ when "maven"
126
+ ->(match) do
127
+ namespace = match[1]
128
+ name = match[2]
129
+ version = match[3]
130
+ { type: type, namespace: namespace, name: name, version: version }
131
+ end
132
+ when "pypi"
133
+ ->(match) do
134
+ name = match[1]
135
+ version = match[2] unless match[2] == name # avoid duplicate name as version
136
+ { type: type, namespace: nil, name: name, version: version }
137
+ end
138
+ when "cargo"
139
+ ->(match) do
140
+ name = match[1]
141
+ { type: type, namespace: nil, name: name, version: nil }
142
+ end
143
+ when "golang"
144
+ ->(match) do
145
+ if match[1] && match[2]
146
+ # Has namespace: pkg.go.dev/namespace/name
147
+ namespace = match[1]
148
+ name = match[2]
149
+ else
150
+ # No namespace: pkg.go.dev/name
151
+ namespace = nil
152
+ name = match[1] || match[2]
153
+ end
154
+ { type: type, namespace: namespace, name: name, version: nil }
155
+ end
156
+ when "hackage"
157
+ ->(match) do
158
+ name = match[1]
159
+ version = match[2] # from name-version pattern
160
+ { type: type, namespace: nil, name: name, version: version }
161
+ end
162
+ when "deno"
163
+ ->(match) do
164
+ name = match[1]
165
+ version = match[2] # from @version pattern
166
+ { type: type, namespace: nil, name: name, version: version }
167
+ end
168
+ when "homebrew"
169
+ ->(match) do
170
+ name = match[1]
171
+ { type: type, namespace: nil, name: name, version: nil }
172
+ end
173
+ when "elm"
174
+ ->(match) do
175
+ namespace = match[1]
176
+ name = match[2]
177
+ version = match[3] unless match[3] == "latest"
178
+ { type: type, namespace: namespace, name: name, version: version }
179
+ end
180
+ else
181
+ ->(match) do
182
+ { type: type, namespace: nil, name: match[1], version: nil }
183
+ end
184
+ end
185
+ end
186
+
187
+ # Registry patterns loaded from JSON configuration
188
+ REGISTRY_PATTERNS = load_registry_patterns.freeze
189
+
190
+ def self.generate(purl)
191
+ new(purl).generate
192
+ end
193
+
194
+ def self.supported_types
195
+ REGISTRY_PATTERNS.keys.sort
196
+ end
197
+
198
+ def self.supports?(type)
199
+ REGISTRY_PATTERNS.key?(type.to_s.downcase)
200
+ end
201
+
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|
205
+ next unless config[:reverse_regex] && config[:reverse_parser]
206
+
207
+ match = registry_url.match(config[:reverse_regex])
208
+ if match
209
+ parsed_data = config[:reverse_parser].call(match)
210
+ return PackageURL.new(
211
+ type: parsed_data[:type],
212
+ namespace: parsed_data[:namespace],
213
+ name: parsed_data[:name],
214
+ version: parsed_data[:version]
215
+ )
216
+ end
217
+ end
218
+
219
+ raise UnsupportedTypeError.new(
220
+ "Unable to parse registry URL: #{registry_url}. No matching pattern found.",
221
+ supported_types: REGISTRY_PATTERNS.keys.select { |k| REGISTRY_PATTERNS[k][:reverse_regex] }
222
+ )
223
+ end
224
+
225
+ def self.supported_reverse_types
226
+ REGISTRY_PATTERNS.select { |_, config| config[:reverse_regex] }.keys.sort
227
+ end
228
+
229
+ def self.route_patterns_for(type)
230
+ pattern_config = REGISTRY_PATTERNS[type.to_s.downcase]
231
+ return [] unless pattern_config
232
+
233
+ pattern_config[:route_patterns] || []
234
+ end
235
+
236
+ def self.all_route_patterns
237
+ result = {}
238
+ REGISTRY_PATTERNS.each do |type, config|
239
+ if config[:route_patterns]
240
+ result[type] = config[:route_patterns]
241
+ end
242
+ end
243
+ result
244
+ end
245
+
246
+ def initialize(purl)
247
+ @purl = purl
248
+ end
249
+
250
+ def generate
251
+ pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
252
+
253
+ unless pattern_config
254
+ raise UnsupportedTypeError.new(
255
+ "No registry URL pattern defined for type '#{@purl.type}'. Supported types: #{self.class.supported_types.join(", ")}",
256
+ type: @purl.type,
257
+ supported_types: self.class.supported_types
258
+ )
259
+ end
260
+
261
+ begin
262
+ pattern_config[:pattern].call(@purl)
263
+ rescue MissingRegistryInfoError
264
+ raise
265
+ rescue => e
266
+ raise RegistryError, "Failed to generate registry URL for #{@purl.type}: #{e.message}"
267
+ end
268
+ end
269
+
270
+ def generate_with_version
271
+ base_url = generate
272
+
273
+ case @purl.type.downcase
274
+ when "npm"
275
+ @purl.version ? "#{base_url}/v/#{@purl.version}" : base_url
276
+ when "pypi"
277
+ @purl.version ? "#{base_url}#{@purl.version}/" : base_url
278
+ when "gem"
279
+ @purl.version ? "#{base_url}/versions/#{@purl.version}" : base_url
280
+ when "maven"
281
+ @purl.version ? "#{base_url}/#{@purl.version}" : base_url
282
+ when "nuget"
283
+ @purl.version ? "#{base_url}/#{@purl.version}" : base_url
284
+ else
285
+ # For other types, just return the base URL since version-specific URLs vary
286
+ base_url
287
+ end
288
+ end
289
+
290
+ private
291
+
292
+ attr_reader :purl
293
+ end
294
+
295
+ # Add registry URL generation methods to PackageURL
296
+ class PackageURL
297
+ def registry_url
298
+ RegistryURL.generate(self)
299
+ end
300
+
301
+ def registry_url_with_version
302
+ RegistryURL.new(self).generate_with_version
303
+ end
304
+
305
+ def supports_registry_url?
306
+ RegistryURL.supports?(type)
307
+ end
308
+ end
309
+ end
data/lib/purl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purl
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/purl.rb CHANGED
@@ -1,8 +1,118 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "purl/version"
4
+ require_relative "purl/errors"
5
+ require_relative "purl/package_url"
6
+ require_relative "purl/registry_url"
4
7
 
5
8
  module Purl
6
9
  class Error < StandardError; end
7
- # Your code goes here...
10
+
11
+ # Load PURL types configuration from JSON file
12
+ def self.load_types_config
13
+ @types_config ||= begin
14
+ config_path = File.join(__dir__, "..", "purl-types.json")
15
+ require "json"
16
+ JSON.parse(File.read(config_path))
17
+ end
18
+ end
19
+
20
+ # Known PURL types loaded from JSON configuration
21
+ KNOWN_TYPES = load_types_config["types"].keys.sort.freeze
22
+
23
+ # Convenience method for parsing PURL strings
24
+ def self.parse(purl_string)
25
+ PackageURL.parse(purl_string)
26
+ end
27
+
28
+ # Convenience method for parsing registry URLs back to PURLs
29
+ def self.from_registry_url(registry_url)
30
+ RegistryURL.from_url(registry_url)
31
+ end
32
+
33
+ # Returns all known PURL types
34
+ def self.known_types
35
+ KNOWN_TYPES.dup
36
+ end
37
+
38
+ # Returns types that have registry URL support
39
+ def self.registry_supported_types
40
+ RegistryURL.supported_types
41
+ end
42
+
43
+ # Returns types that support reverse parsing from registry URLs
44
+ def self.reverse_parsing_supported_types
45
+ RegistryURL.supported_reverse_types
46
+ end
47
+
48
+ # Check if a type is known/valid
49
+ def self.known_type?(type)
50
+ KNOWN_TYPES.include?(type.to_s.downcase)
51
+ end
52
+
53
+ # Get type information including registry support
54
+ def self.type_info(type)
55
+ normalized_type = type.to_s.downcase
56
+ {
57
+ type: normalized_type,
58
+ known: known_type?(normalized_type),
59
+ registry_url_generation: RegistryURL.supports?(normalized_type),
60
+ reverse_parsing: RegistryURL.supported_reverse_types.include?(normalized_type),
61
+ route_patterns: RegistryURL.route_patterns_for(normalized_type)
62
+ }
63
+ end
64
+
65
+ # Get comprehensive information about all types
66
+ def self.all_type_info
67
+ result = {}
68
+
69
+ # Start with known types
70
+ KNOWN_TYPES.each do |type|
71
+ result[type] = type_info(type)
72
+ end
73
+
74
+ # Add any registry-supported types not in known list
75
+ RegistryURL.supported_types.each do |type|
76
+ unless result.key?(type)
77
+ result[type] = type_info(type)
78
+ end
79
+ end
80
+
81
+ result
82
+ end
83
+
84
+ # Get type configuration from JSON
85
+ def self.type_config(type)
86
+ config = load_types_config["types"][type.to_s.downcase]
87
+ return nil unless config
88
+
89
+ config.dup # Return a copy to prevent modification
90
+ end
91
+
92
+ # Get description for a type
93
+ def self.type_description(type)
94
+ config = type_config(type)
95
+ config ? config["description"] : nil
96
+ end
97
+
98
+ # Get registry configuration for a type
99
+ def self.registry_config(type)
100
+ config = type_config(type)
101
+ return nil unless config
102
+
103
+ config["registry_config"]
104
+ end
105
+
106
+ # Get metadata about the types configuration
107
+ def self.types_config_metadata
108
+ config = load_types_config
109
+ {
110
+ version: config["version"],
111
+ description: config["description"],
112
+ source: config["source"],
113
+ last_updated: config["last_updated"],
114
+ total_types: config["types"].keys.length,
115
+ registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length
116
+ }
117
+ end
8
118
  end