archsight 0.1.2 → 0.1.3
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.
- checksums.yaml +4 -4
- data/README.md +26 -5
- data/lib/archsight/analysis/executor.rb +112 -0
- data/lib/archsight/analysis/result.rb +174 -0
- data/lib/archsight/analysis/sandbox.rb +319 -0
- data/lib/archsight/analysis.rb +11 -0
- data/lib/archsight/annotations/architecture_annotations.rb +2 -2
- data/lib/archsight/cli.rb +163 -0
- data/lib/archsight/database.rb +6 -2
- data/lib/archsight/helpers/analysis_renderer.rb +83 -0
- data/lib/archsight/helpers/formatting.rb +95 -0
- data/lib/archsight/helpers.rb +20 -4
- data/lib/archsight/import/concurrent_progress.rb +341 -0
- data/lib/archsight/import/executor.rb +466 -0
- data/lib/archsight/import/git_analytics.rb +626 -0
- data/lib/archsight/import/handler.rb +263 -0
- data/lib/archsight/import/handlers/github.rb +161 -0
- data/lib/archsight/import/handlers/gitlab.rb +202 -0
- data/lib/archsight/import/handlers/jira_base.rb +189 -0
- data/lib/archsight/import/handlers/jira_discover.rb +161 -0
- data/lib/archsight/import/handlers/jira_metrics.rb +179 -0
- data/lib/archsight/import/handlers/openapi_schema_parser.rb +279 -0
- data/lib/archsight/import/handlers/repository.rb +439 -0
- data/lib/archsight/import/handlers/rest_api.rb +293 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +183 -0
- data/lib/archsight/import/progress.rb +91 -0
- data/lib/archsight/import/registry.rb +54 -0
- data/lib/archsight/import/shared_file_writer.rb +67 -0
- data/lib/archsight/import/team_matcher.rb +195 -0
- data/lib/archsight/import.rb +14 -0
- data/lib/archsight/resources/analysis.rb +91 -0
- data/lib/archsight/resources/application_component.rb +2 -2
- data/lib/archsight/resources/application_service.rb +12 -12
- data/lib/archsight/resources/business_product.rb +12 -12
- data/lib/archsight/resources/data_object.rb +1 -1
- data/lib/archsight/resources/import.rb +79 -0
- data/lib/archsight/resources/technology_artifact.rb +23 -2
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/docs.rb +17 -0
- data/lib/archsight/web/api/json_helpers.rb +164 -0
- data/lib/archsight/web/api/openapi/spec.yaml +500 -0
- data/lib/archsight/web/api/routes.rb +101 -0
- data/lib/archsight/web/application.rb +66 -43
- data/lib/archsight/web/doc/import.md +458 -0
- data/lib/archsight/web/doc/index.md.erb +1 -0
- data/lib/archsight/web/public/css/artifact.css +10 -0
- data/lib/archsight/web/public/css/graph.css +14 -0
- data/lib/archsight/web/public/css/instance.css +489 -0
- data/lib/archsight/web/views/api_docs.erb +19 -0
- data/lib/archsight/web/views/partials/artifact/_project_estimate.haml +14 -8
- data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +74 -0
- data/lib/archsight/web/views/partials/instance/_analysis_result.haml +64 -0
- data/lib/archsight/web/views/partials/instance/_detail.haml +7 -3
- data/lib/archsight/web/views/partials/instance/_import_detail.haml +87 -0
- data/lib/archsight/web/views/partials/instance/_relations.haml +4 -4
- data/lib/archsight/web/views/partials/layout/_content.haml +4 -0
- data/lib/archsight/web/views/partials/layout/_navigation.haml +6 -5
- metadata +78 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require_relative "../handler"
|
|
9
|
+
require_relative "../registry"
|
|
10
|
+
require_relative "openapi_schema_parser"
|
|
11
|
+
|
|
12
|
+
# REST API handler - downloads OpenAPI spec and generates ApplicationInterface and DataObject resources
|
|
13
|
+
#
|
|
14
|
+
# Configuration (passed from rest_api_index handler):
|
|
15
|
+
# import/config/name - API name (e.g., "compute")
|
|
16
|
+
# import/config/version - API version (e.g., "6.0")
|
|
17
|
+
# import/config/visibility - API visibility (e.g., "private")
|
|
18
|
+
# import/config/specUrl - Full URL to OpenAPI spec (http, https, or file://)
|
|
19
|
+
# import/config/htmlUrl - Full URL to HTML documentation (optional)
|
|
20
|
+
# import/config/gate - Release gate (e.g., "GA", "BETA")
|
|
21
|
+
# import/config/interfaceOutputPath - Output path for ApplicationInterface resources
|
|
22
|
+
# import/config/dataObjectOutputPath - Output path for DataObject resources
|
|
23
|
+
#
|
|
24
|
+
# Output:
|
|
25
|
+
# - ApplicationInterface resource with annotations
|
|
26
|
+
# - DataObject resources extracted from OpenAPI schemas
|
|
27
|
+
class Archsight::Import::Handlers::RestApi < Archsight::Import::Handler
|
|
28
|
+
def execute
|
|
29
|
+
@name = config("name")
|
|
30
|
+
raise "Missing required config: name" unless @name
|
|
31
|
+
|
|
32
|
+
@version = config("version", default: "1.0")
|
|
33
|
+
@visibility = config("visibility", default: "private")
|
|
34
|
+
@spec_url = config("specUrl")
|
|
35
|
+
raise "Missing required config: specUrl" unless @spec_url
|
|
36
|
+
|
|
37
|
+
@html_url = config("htmlUrl")
|
|
38
|
+
@gate = config("gate", default: "GA")
|
|
39
|
+
|
|
40
|
+
@interface_output_path = config("interfaceOutputPath")
|
|
41
|
+
@data_object_output_path = config("dataObjectOutputPath")
|
|
42
|
+
|
|
43
|
+
# Download and parse OpenAPI spec
|
|
44
|
+
progress.update("Downloading OpenAPI spec for #{@name}")
|
|
45
|
+
openapi_doc = fetch_openapi_spec(@spec_url)
|
|
46
|
+
|
|
47
|
+
# Parse schemas and generate DataObjects first (needed for interface relations)
|
|
48
|
+
progress.update("Extracting DataObjects from #{@name} schemas")
|
|
49
|
+
parser = Archsight::Import::Handlers::OpenAPISchemaParser.new(openapi_doc)
|
|
50
|
+
parsed_objects = parser.parse
|
|
51
|
+
data_objects = generate_data_objects(parsed_objects)
|
|
52
|
+
|
|
53
|
+
# Generate ApplicationInterface with references to DataObjects
|
|
54
|
+
progress.update("Generating ApplicationInterface for #{@name}")
|
|
55
|
+
data_object_names = data_objects.map { |obj| obj.dig("metadata", "name") }
|
|
56
|
+
interface_resource = generate_interface(openapi_doc, data_object_names: data_object_names)
|
|
57
|
+
|
|
58
|
+
# Write outputs
|
|
59
|
+
if @interface_output_path
|
|
60
|
+
write_yaml(YAML.dump(interface_resource), filename: nil, sort_key: "#{@name}-interface")
|
|
61
|
+
else
|
|
62
|
+
write_yaml(YAML.dump(interface_resource), filename: "#{safe_api_name}-interface.yaml")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if data_objects.any?
|
|
66
|
+
data_yaml = data_objects.map { |r| YAML.dump(r) }.join("\n")
|
|
67
|
+
if @data_object_output_path
|
|
68
|
+
# Use shared output path - write each object with a sort key
|
|
69
|
+
data_objects.each do |obj|
|
|
70
|
+
write_data_object(obj)
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
write_yaml(data_yaml, filename: "#{safe_api_name}-data-objects.yaml")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
progress.update("Generated #{data_objects.size} DataObjects for #{@name}")
|
|
78
|
+
|
|
79
|
+
write_generates_meta
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def safe_api_name
|
|
85
|
+
@name.gsub(/[^a-zA-Z0-9_-]/, "_").downcase
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_openapi_spec(url)
|
|
89
|
+
uri = URI(url)
|
|
90
|
+
|
|
91
|
+
case uri.scheme
|
|
92
|
+
when "file"
|
|
93
|
+
fetch_file_spec(uri)
|
|
94
|
+
when "http", "https"
|
|
95
|
+
fetch_http_spec(uri)
|
|
96
|
+
else
|
|
97
|
+
raise "Unsupported URL scheme: #{uri.scheme}. Use http://, https://, or file://"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fetch_file_spec(uri)
|
|
102
|
+
# Handle file:// URLs - the host part may be treated as part of the path
|
|
103
|
+
# e.g., file://lib/path becomes host=lib, path=/path
|
|
104
|
+
# We need to reconstruct: host + path
|
|
105
|
+
path = if uri.host && uri.host != "localhost" && !uri.host.empty?
|
|
106
|
+
"#{uri.host}#{uri.path}"
|
|
107
|
+
else
|
|
108
|
+
uri.path
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise "File not found: #{path}" unless File.exist?(path)
|
|
112
|
+
|
|
113
|
+
content = File.read(path)
|
|
114
|
+
parse_spec_content(content)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fetch_http_spec(uri)
|
|
118
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
119
|
+
http.use_ssl = uri.scheme == "https"
|
|
120
|
+
http.open_timeout = 30
|
|
121
|
+
http.read_timeout = 60
|
|
122
|
+
|
|
123
|
+
request = Net::HTTP::Get.new(uri)
|
|
124
|
+
request["Accept"] = "application/yaml, application/json"
|
|
125
|
+
|
|
126
|
+
response = http.request(request)
|
|
127
|
+
|
|
128
|
+
case response
|
|
129
|
+
when Net::HTTPSuccess
|
|
130
|
+
parse_spec_content(response.body)
|
|
131
|
+
when Net::HTTPRedirection
|
|
132
|
+
# Follow redirect
|
|
133
|
+
fetch_openapi_spec(response["location"])
|
|
134
|
+
else
|
|
135
|
+
raise "Failed to fetch OpenAPI spec from #{uri}: #{response.code} #{response.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_spec_content(content)
|
|
140
|
+
# Try YAML first, then JSON
|
|
141
|
+
YAML.safe_load(content, permitted_classes: [Date, Time])
|
|
142
|
+
rescue Psych::SyntaxError
|
|
143
|
+
JSON.parse(content)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def generate_interface(openapi_doc, data_object_names: [])
|
|
147
|
+
info = openapi_doc["info"] || {}
|
|
148
|
+
title = info["title"] || @name.capitalize
|
|
149
|
+
description = info["description"] || ""
|
|
150
|
+
openapi_version = openapi_doc["openapi"] || "3.0.0"
|
|
151
|
+
|
|
152
|
+
# Detect technologies from spec
|
|
153
|
+
tags = detect_technologies(openapi_doc)
|
|
154
|
+
|
|
155
|
+
# Build resource name
|
|
156
|
+
interface_name = build_interface_name
|
|
157
|
+
|
|
158
|
+
annotations = {
|
|
159
|
+
"architecture/title" => title,
|
|
160
|
+
"architecture/description" => description.strip,
|
|
161
|
+
"architecture/openapi" => openapi_version,
|
|
162
|
+
"architecture/version" => @version,
|
|
163
|
+
"architecture/status" => @gate,
|
|
164
|
+
"architecture/visibility" => @visibility,
|
|
165
|
+
"architecture/tags" => tags.join(","),
|
|
166
|
+
"architecture/encoding" => "json"
|
|
167
|
+
}
|
|
168
|
+
annotations["architecture/documentation"] = @html_url if @html_url
|
|
169
|
+
|
|
170
|
+
# Build spec with technology relations and data objects
|
|
171
|
+
spec = {
|
|
172
|
+
"servedBy" => {
|
|
173
|
+
"technologyComponents" => build_technology_components(tags)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Add relation to DataObjects if any were generated
|
|
178
|
+
spec["serves"] = { "dataObjects" => data_object_names } if data_object_names.any?
|
|
179
|
+
|
|
180
|
+
resource_yaml(
|
|
181
|
+
kind: "ApplicationInterface",
|
|
182
|
+
name: interface_name,
|
|
183
|
+
annotations: annotations,
|
|
184
|
+
spec: spec
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_interface_name
|
|
189
|
+
visibility_prefix = @visibility.split("-").map(&:capitalize).join
|
|
190
|
+
api_name = @name.split(/[-_]/).map(&:capitalize).join
|
|
191
|
+
# Handle version that may already have "v" prefix (e.g., "v1" or "1.0")
|
|
192
|
+
version_str = @version.split(".").first
|
|
193
|
+
version_str = version_str.sub(/^v/i, "") # Remove leading v if present
|
|
194
|
+
"#{visibility_prefix}:#{api_name}:v#{version_str}:RestAPI"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def detect_technologies(openapi_doc)
|
|
198
|
+
tags = %w[https rest]
|
|
199
|
+
|
|
200
|
+
# Check security schemes
|
|
201
|
+
security_schemes = openapi_doc.dig("components", "securitySchemes") || {}
|
|
202
|
+
|
|
203
|
+
security_schemes.each_value do |scheme|
|
|
204
|
+
case scheme["type"]
|
|
205
|
+
when "http"
|
|
206
|
+
case scheme["scheme"]&.downcase
|
|
207
|
+
when "bearer"
|
|
208
|
+
tags << "jwt" if scheme["bearerFormat"]&.downcase == "jwt"
|
|
209
|
+
tags << "bearer" unless tags.include?("jwt")
|
|
210
|
+
when "basic"
|
|
211
|
+
tags << "basic-auth"
|
|
212
|
+
end
|
|
213
|
+
when "apiKey"
|
|
214
|
+
tags << "api-key"
|
|
215
|
+
when "oauth2"
|
|
216
|
+
tags << "oauth2"
|
|
217
|
+
when "openIdConnect"
|
|
218
|
+
tags << "oidc"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Check for specific headers or patterns
|
|
223
|
+
tags << "auth" if openapi_doc["paths"]&.to_s&.include?("Authorization") && tags.none? { |t| t.include?("auth") || t == "jwt" || t == "bearer" }
|
|
224
|
+
|
|
225
|
+
tags.uniq
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_technology_components(tags)
|
|
229
|
+
components = ["HTTPS:REST"]
|
|
230
|
+
|
|
231
|
+
tags.each do |tag|
|
|
232
|
+
case tag
|
|
233
|
+
when "jwt"
|
|
234
|
+
components << "AUTH:JWT"
|
|
235
|
+
when "basic-auth"
|
|
236
|
+
components << "AUTH:Basic"
|
|
237
|
+
when "api-key"
|
|
238
|
+
components << "AUTH:APIKey"
|
|
239
|
+
when "oauth2"
|
|
240
|
+
components << "AUTH:OAuth2"
|
|
241
|
+
when "oidc"
|
|
242
|
+
components << "AUTH:OIDC"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
components.uniq
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generate_data_objects(parsed_objects)
|
|
250
|
+
app_name = @name.split(/[-_]/).map(&:capitalize).join
|
|
251
|
+
|
|
252
|
+
parsed_objects.map do |normalized_name, info|
|
|
253
|
+
object_name = "#{app_name}:#{normalized_name}"
|
|
254
|
+
|
|
255
|
+
# Build description with field documentation
|
|
256
|
+
description_parts = []
|
|
257
|
+
description_parts << info["description"] if info["description"]
|
|
258
|
+
|
|
259
|
+
if info["properties"]&.any?
|
|
260
|
+
field_docs = Archsight::Import::Handlers::OpenAPISchemaParser.generate_field_docs(info["properties"])
|
|
261
|
+
description_parts << field_docs unless field_docs.empty?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
annotations = {
|
|
265
|
+
"data/application" => app_name,
|
|
266
|
+
"data/visibility" => @visibility,
|
|
267
|
+
"architecture/description" => description_parts.join("\n\n"),
|
|
268
|
+
"generated/variants" => info["original_names"].join(", ")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
resource_yaml(
|
|
272
|
+
kind: "DataObject",
|
|
273
|
+
name: object_name,
|
|
274
|
+
annotations: annotations,
|
|
275
|
+
spec: {}
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def write_data_object(obj)
|
|
281
|
+
# Override the output path for data objects
|
|
282
|
+
original_output_path = import_resource.annotations["import/outputPath"]
|
|
283
|
+
import_resource.annotations["import/outputPath"] = @data_object_output_path
|
|
284
|
+
|
|
285
|
+
name = obj.dig("metadata", "name")
|
|
286
|
+
write_yaml(YAML.dump(obj), sort_key: name)
|
|
287
|
+
|
|
288
|
+
# Restore original output path
|
|
289
|
+
import_resource.annotations["import/outputPath"] = original_output_path
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
Archsight::Import::Registry.register("rest-api", Archsight::Import::Handlers::RestApi)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require_relative "../handler"
|
|
9
|
+
require_relative "../registry"
|
|
10
|
+
|
|
11
|
+
# REST API Index handler - fetches API index and generates child Import resources
|
|
12
|
+
#
|
|
13
|
+
# Configuration:
|
|
14
|
+
# import/config/indexUrl - URL to fetch API index JSON (required)
|
|
15
|
+
# import/config/baseUrl - Base URL for spec files (optional, derived from indexUrl)
|
|
16
|
+
# import/config/interfaceOutputPath - Shared output for ApplicationInterface resources
|
|
17
|
+
# import/config/dataObjectOutputPath - Shared output for DataObject resources
|
|
18
|
+
# import/config/skipVisibility - Comma-separated visibilities to skip (e.g., "public-preview")
|
|
19
|
+
# import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
|
|
20
|
+
#
|
|
21
|
+
# Output:
|
|
22
|
+
# Generates Import:RestApi:* resources for each API in the index
|
|
23
|
+
#
|
|
24
|
+
# Expected index format (object with "pages" array):
|
|
25
|
+
# {
|
|
26
|
+
# "pages": [
|
|
27
|
+
# {
|
|
28
|
+
# "name": "compute",
|
|
29
|
+
# "version": "v1",
|
|
30
|
+
# "visibility": "public",
|
|
31
|
+
# "spec": "/rest-api/public-compute-v1.yaml",
|
|
32
|
+
# "redoc": "/rest-api/docs/compute/v1/",
|
|
33
|
+
# "gate": "General-Availability"
|
|
34
|
+
# },
|
|
35
|
+
# ...
|
|
36
|
+
# ]
|
|
37
|
+
# }
|
|
38
|
+
class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
|
|
39
|
+
def execute
|
|
40
|
+
@index_url = config("indexUrl")
|
|
41
|
+
raise "Missing required config: indexUrl" unless @index_url
|
|
42
|
+
|
|
43
|
+
@base_url = config("baseUrl") || derive_base_url(@index_url)
|
|
44
|
+
@interface_output_path = config("interfaceOutputPath")
|
|
45
|
+
@data_object_output_path = config("dataObjectOutputPath")
|
|
46
|
+
@skip_visibilities = (config("skipVisibility") || "").split(",").map(&:strip).reject(&:empty?)
|
|
47
|
+
@child_cache_time = config("childCacheTime")
|
|
48
|
+
|
|
49
|
+
# Fetch API index
|
|
50
|
+
progress.update("Fetching API index from #{@index_url}")
|
|
51
|
+
apis = fetch_index
|
|
52
|
+
|
|
53
|
+
if apis.empty?
|
|
54
|
+
progress.warn("No APIs found in index")
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Filter APIs by visibility
|
|
59
|
+
original_count = apis.size
|
|
60
|
+
apis = filter_apis(apis)
|
|
61
|
+
progress.update("Filtered to #{apis.size} APIs (skipped #{original_count - apis.size} by visibility)") if apis.size < original_count
|
|
62
|
+
|
|
63
|
+
# Generate child imports
|
|
64
|
+
progress.update("Generating #{apis.size} import resources")
|
|
65
|
+
generate_api_imports(apis)
|
|
66
|
+
|
|
67
|
+
write_generates_meta
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def derive_base_url(url)
|
|
73
|
+
uri = URI(url)
|
|
74
|
+
"#{uri.scheme}://#{uri.host}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_index
|
|
78
|
+
uri = URI(@index_url)
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = uri.scheme == "https"
|
|
81
|
+
http.open_timeout = 30
|
|
82
|
+
http.read_timeout = 60
|
|
83
|
+
|
|
84
|
+
request = Net::HTTP::Get.new(uri)
|
|
85
|
+
request["Accept"] = "application/json"
|
|
86
|
+
|
|
87
|
+
response = http.request(request)
|
|
88
|
+
|
|
89
|
+
case response
|
|
90
|
+
when Net::HTTPSuccess
|
|
91
|
+
data = JSON.parse(response.body)
|
|
92
|
+
# Handle both array format and object with "pages" key
|
|
93
|
+
data.is_a?(Array) ? data : (data["pages"] || [])
|
|
94
|
+
when Net::HTTPRedirection
|
|
95
|
+
# Follow redirect
|
|
96
|
+
@index_url = response["location"]
|
|
97
|
+
fetch_index
|
|
98
|
+
when Net::HTTPUnauthorized
|
|
99
|
+
raise "API index error: 401 Unauthorized - Check credentials"
|
|
100
|
+
when Net::HTTPForbidden
|
|
101
|
+
raise "API index error: 403 Forbidden - Access denied"
|
|
102
|
+
when Net::HTTPNotFound
|
|
103
|
+
raise "API index error: 404 Not Found - Index not found at #{@index_url}"
|
|
104
|
+
else
|
|
105
|
+
raise "API index error: #{response.code} #{response.message}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def filter_apis(apis)
|
|
110
|
+
return apis if @skip_visibilities.empty?
|
|
111
|
+
|
|
112
|
+
apis.reject do |api|
|
|
113
|
+
visibility = api["visibility"]&.downcase
|
|
114
|
+
@skip_visibilities.any? { |skip| visibility == skip.downcase }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def generate_api_imports(apis)
|
|
119
|
+
yaml_documents = apis.map do |api|
|
|
120
|
+
api_name = api["name"]
|
|
121
|
+
api_version = api["version"] || "v1"
|
|
122
|
+
|
|
123
|
+
# Build full URLs from base URL and paths
|
|
124
|
+
# Support both "spec"/"redoc" (new format) and "specPath"/"redocPath" (legacy)
|
|
125
|
+
spec_path = api["spec"] || api["specPath"]
|
|
126
|
+
redoc_path = api["redoc"] || api["redocPath"]
|
|
127
|
+
spec_url = build_full_url(spec_path)
|
|
128
|
+
html_url = redoc_path ? build_full_url(redoc_path) : nil
|
|
129
|
+
|
|
130
|
+
# Derive visibility from spec path if not explicitly provided
|
|
131
|
+
visibility = api["visibility"] || derive_visibility_from_path(spec_path)
|
|
132
|
+
|
|
133
|
+
# Include visibility in import name to distinguish public/private versions of same API
|
|
134
|
+
import_name = "Import:RestApi:#{visibility}:#{api_name}:#{api_version}"
|
|
135
|
+
child_config = {
|
|
136
|
+
"name" => api_name,
|
|
137
|
+
"version" => api["version"] || "1.0",
|
|
138
|
+
"visibility" => visibility,
|
|
139
|
+
"specUrl" => spec_url,
|
|
140
|
+
"gate" => api["gate"] || "GA"
|
|
141
|
+
}
|
|
142
|
+
child_config["htmlUrl"] = html_url if html_url
|
|
143
|
+
|
|
144
|
+
# Build annotations for child import
|
|
145
|
+
child_annotations = {}
|
|
146
|
+
child_annotations["import/outputPath"] = @interface_output_path if @interface_output_path
|
|
147
|
+
child_annotations["import/cacheTime"] = @child_cache_time if @child_cache_time
|
|
148
|
+
child_annotations["import/config/interfaceOutputPath"] = @interface_output_path if @interface_output_path
|
|
149
|
+
child_annotations["import/config/dataObjectOutputPath"] = @data_object_output_path if @data_object_output_path
|
|
150
|
+
|
|
151
|
+
import_yaml(
|
|
152
|
+
name: import_name,
|
|
153
|
+
handler: "rest-api",
|
|
154
|
+
config: child_config,
|
|
155
|
+
annotations: child_annotations
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Add self-marker with generated/at for caching
|
|
160
|
+
yaml_documents << self_marker
|
|
161
|
+
|
|
162
|
+
# Write all imports to a single file
|
|
163
|
+
yaml_content = yaml_documents.map { |doc| YAML.dump(doc) }.join("\n")
|
|
164
|
+
write_yaml(yaml_content)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_full_url(path)
|
|
168
|
+
return path if path.start_with?("http://", "https://", "file://")
|
|
169
|
+
|
|
170
|
+
"#{@base_url}#{path}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Derive visibility from spec path patterns like "/rest-api/public-compute-v1.yaml"
|
|
174
|
+
# or "/docs/public/compute/v1/"
|
|
175
|
+
def derive_visibility_from_path(path)
|
|
176
|
+
return "public" if path&.match?(/\bpublic\b/i)
|
|
177
|
+
return "private" if path&.match?(/\bprivate\b/i)
|
|
178
|
+
|
|
179
|
+
"public" # Default to public for APIs without explicit visibility marker
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
Archsight::Import::Registry.register("rest-api-index", Archsight::Import::Handlers::RestApiIndex)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Progress reporter for import operations
|
|
4
|
+
#
|
|
5
|
+
# In TTY mode: updates a single line with \r
|
|
6
|
+
# In non-TTY mode (CI): prints each update on a new line
|
|
7
|
+
class Archsight::Import::Progress
|
|
8
|
+
def initialize(output: $stdout)
|
|
9
|
+
@output = output
|
|
10
|
+
@tty = output.respond_to?(:tty?) && output.tty?
|
|
11
|
+
@last_line_length = 0
|
|
12
|
+
@current_context = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def tty?
|
|
16
|
+
@tty
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Set the current context (e.g., "Import:GitLab")
|
|
20
|
+
def context=(name)
|
|
21
|
+
@current_context = name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Report progress with optional sub-progress
|
|
25
|
+
# Examples:
|
|
26
|
+
# update("Fetching projects...")
|
|
27
|
+
# update("Cloning", current: 5, total: 100)
|
|
28
|
+
def update(message, current: nil, total: nil)
|
|
29
|
+
line = build_line(message, current, total)
|
|
30
|
+
|
|
31
|
+
if @tty
|
|
32
|
+
# Clear previous line and write new one
|
|
33
|
+
clear = "\r#{" " * @last_line_length}\r"
|
|
34
|
+
@output.print "#{clear}#{line}"
|
|
35
|
+
@output.flush
|
|
36
|
+
@last_line_length = line.length
|
|
37
|
+
else
|
|
38
|
+
@output.puts line
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Complete the current operation (moves to new line in TTY mode)
|
|
43
|
+
def complete(message = nil)
|
|
44
|
+
if message
|
|
45
|
+
line = build_line(message)
|
|
46
|
+
if @tty
|
|
47
|
+
clear = "\r#{" " * @last_line_length}\r"
|
|
48
|
+
@output.puts "#{clear}#{line}"
|
|
49
|
+
else
|
|
50
|
+
@output.puts line
|
|
51
|
+
end
|
|
52
|
+
elsif @tty
|
|
53
|
+
@output.puts
|
|
54
|
+
end
|
|
55
|
+
@last_line_length = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Report an error
|
|
59
|
+
def error(message)
|
|
60
|
+
complete if @tty && @last_line_length.positive?
|
|
61
|
+
@output.puts " Error: #{message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Report a warning
|
|
65
|
+
def warn(message)
|
|
66
|
+
if @tty && @last_line_length.positive?
|
|
67
|
+
# Save current line, print warning, restore
|
|
68
|
+
@output.puts
|
|
69
|
+
@output.puts " Warning: #{message}"
|
|
70
|
+
@last_line_length = 0
|
|
71
|
+
else
|
|
72
|
+
@output.puts " Warning: #{message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_line(message, current = nil, total = nil)
|
|
79
|
+
parts = []
|
|
80
|
+
parts << @current_context if @current_context
|
|
81
|
+
parts << progress_indicator(current, total) if current && total
|
|
82
|
+
parts << message
|
|
83
|
+
|
|
84
|
+
parts.join(" - ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def progress_indicator(current, total)
|
|
88
|
+
percentage = ((current.to_f / total) * 100).round
|
|
89
|
+
"[#{current}/#{total} #{percentage}%]"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "archsight/import"
|
|
4
|
+
|
|
5
|
+
# Registry of import handlers
|
|
6
|
+
#
|
|
7
|
+
# Handlers register themselves with a name that matches the import/handler annotation.
|
|
8
|
+
# The executor looks up handlers by name to instantiate and execute them.
|
|
9
|
+
module Archsight::Import::Registry
|
|
10
|
+
@handlers = {}
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Register a handler class with a name
|
|
14
|
+
# @param name [String, Symbol] Handler name (matches import/handler annotation)
|
|
15
|
+
# @param handler_class [Class] Handler class that extends Handler
|
|
16
|
+
def register(name, handler_class)
|
|
17
|
+
@handlers[name.to_s] = handler_class
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Look up a handler class by name
|
|
21
|
+
# @param name [String] Handler name
|
|
22
|
+
# @return [Class, nil] Handler class or nil if not found
|
|
23
|
+
def [](name)
|
|
24
|
+
@handlers[name.to_s]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get handler class for an import resource
|
|
28
|
+
# @param import_resource [Archsight::Resources::Import] Import resource
|
|
29
|
+
# @return [Class] Handler class
|
|
30
|
+
# @raise [UnknownHandlerError] if handler is not registered
|
|
31
|
+
def handler_for(import_resource)
|
|
32
|
+
handler_name = import_resource.annotations["import/handler"]
|
|
33
|
+
handler_class = self[handler_name]
|
|
34
|
+
|
|
35
|
+
raise Archsight::Import::UnknownHandlerError, "Unknown import handler: #{handler_name}" unless handler_class
|
|
36
|
+
|
|
37
|
+
handler_class
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# List all registered handler names
|
|
41
|
+
# @return [Array<String>] Handler names
|
|
42
|
+
def handlers
|
|
43
|
+
@handlers.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clear all registered handlers (for testing)
|
|
47
|
+
def clear!
|
|
48
|
+
@handlers = {}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Error raised when an unknown handler is requested
|
|
54
|
+
class Archsight::Import::UnknownHandlerError < StandardError; end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
# Thread-safe file writer for concurrent import handlers
|
|
6
|
+
#
|
|
7
|
+
# Manages shared output files that multiple handlers can write to.
|
|
8
|
+
# Content is buffered in memory and sorted by key when close_all is called.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# writer = SharedFileWriter.new
|
|
12
|
+
# writer.append_yaml("/path/to/output.yaml", yaml_content, sort_key: "Repo:name")
|
|
13
|
+
# writer.close_all # Sorts and writes buffered content
|
|
14
|
+
class Archsight::Import::SharedFileWriter
|
|
15
|
+
def initialize
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@files = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Append YAML content to a file (thread-safe, buffered)
|
|
21
|
+
# Content is sorted by sort_key when close_all is called
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] Full path to the output file
|
|
24
|
+
# @param content [String] YAML content to append
|
|
25
|
+
# @param sort_key [String, nil] Key for sorting (nil keys go last)
|
|
26
|
+
def append_yaml(path, content, sort_key: nil)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@files[path] ||= { entries: [], lock: Mutex.new }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
entry = @files[path]
|
|
32
|
+
entry[:lock].synchronize do
|
|
33
|
+
entry[:entries] << { key: sort_key, content: content }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Close all files - sorts and writes buffered content
|
|
38
|
+
def close_all
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@files.each do |path, entry|
|
|
41
|
+
write_sorted_file(path, entry[:entries])
|
|
42
|
+
end
|
|
43
|
+
@files.clear
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def write_sorted_file(path, entries)
|
|
50
|
+
return if entries.empty?
|
|
51
|
+
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
53
|
+
|
|
54
|
+
# Sort by key (nil keys go last)
|
|
55
|
+
sorted = entries.sort_by { |e| e[:key] || "\xFF" }
|
|
56
|
+
|
|
57
|
+
File.open(path, "w") do |file|
|
|
58
|
+
sorted.each_with_index do |entry, idx|
|
|
59
|
+
content = entry[:content]
|
|
60
|
+
# Add document separator if not first and content doesn't have one
|
|
61
|
+
file.write("---\n") if idx.positive? && !content.start_with?("---")
|
|
62
|
+
file.write(content)
|
|
63
|
+
file.write("\n") unless content.end_with?("\n")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|