rack-charts-api 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9d9e73cfb5510f3759cb0427a5e0520731add2d39b679ba3cd982b9b483dfac9
4
+ data.tar.gz: 4cb7889529666731b1943f765c842ecac86eeb66eef5d802ffd3d7e9ea0fd688
5
+ SHA512:
6
+ metadata.gz: bd5cfb122e6efe0a49d3419e923906956a48c541aeb935933b2b373ada2a248ba6e71bc6c268fabeb99fa1723fa3b5b3785e8cab7c593027dce775df10c6e200
7
+ data.tar.gz: 59a45e4696df7261e46e04196837489537b8d62e1655dd7755e46ad0bb6b7fe91bd1f4cf173901731be41438c73ab8243a233a46986bf4c3b3dfd016448438be
@@ -0,0 +1,109 @@
1
+ module Rack
2
+ class ChartsApi
3
+ # The core Rack application. Can be used standalone or behind the
4
+ # ChartsApi middleware.
5
+ #
6
+ # Response format is determined by the URL extension:
7
+ #
8
+ # GET /charts.png?data=...&w=800&h=400 -> PNG image
9
+ # GET /charts.html?data=... -> HTML page with Chart.js chart
10
+ # GET /charts?data=... -> HTML (default)
11
+ # POST /charts.png (JSON body) -> PNG image
12
+ # POST /charts.html (JSON body) -> HTML page
13
+ #
14
+ class App
15
+ def call(env)
16
+ data, opts = DataParser.parse(env)
17
+
18
+ unless data
19
+ return [
20
+ 400,
21
+ { 'content-type' => 'application/json' },
22
+ ['{"error":"Missing or invalid chart data. Pass a `data` param (JSON) or POST a JSON body with a `data` key."}']
23
+ ]
24
+ end
25
+
26
+ format = detect_format(env)
27
+
28
+ case format
29
+ when :png
30
+ render_png(data, opts)
31
+ else
32
+ render_html(env, data, opts)
33
+ end
34
+ rescue StandardError => e
35
+ [
36
+ 500,
37
+ { 'content-type' => 'application/json' },
38
+ [{ error: e.message }.to_json]
39
+ ]
40
+ end
41
+
42
+ private
43
+
44
+ def detect_format(env)
45
+ path = env['PATH_INFO'].to_s
46
+
47
+ if path.end_with?('.png')
48
+ :png
49
+ elsif path.end_with?('.html')
50
+ :html
51
+ else
52
+ # Check Accept header
53
+ accept = env['HTTP_ACCEPT'].to_s
54
+ if accept.include?('image/png')
55
+ :png
56
+ else
57
+ :html
58
+ end
59
+ end
60
+ end
61
+
62
+ def render_png(data, opts)
63
+ blob = PngRenderer.render(data, opts)
64
+ [
65
+ 200,
66
+ {
67
+ 'content-type' => 'image/png',
68
+ 'content-length' => blob.bytesize.to_s,
69
+ 'content-disposition' => 'inline; filename="chart.png"',
70
+ 'cache-control' => 'no-cache'
71
+ },
72
+ [blob]
73
+ ]
74
+ end
75
+
76
+ def render_html(env, data, opts)
77
+ # For the HTML renderer, we want the original chartkick-compatible
78
+ # JSON, not the normalised hash. Re-parse to get the raw data.
79
+ raw_data = extract_raw_data(env)
80
+ html = HtmlRenderer.render(raw_data || data, opts)
81
+ [
82
+ 200,
83
+ {
84
+ 'content-type' => 'text/html; charset=utf-8',
85
+ 'content-length' => html.bytesize.to_s,
86
+ 'cache-control' => 'no-cache'
87
+ },
88
+ [html]
89
+ ]
90
+ end
91
+
92
+ def extract_raw_data(env)
93
+ request = Rack::Request.new(env)
94
+
95
+ if request.post? && DataParser.json_content?(env)
96
+ body = env['rack.input'].read
97
+ env['rack.input'].rewind
98
+ payload = JSON.parse(body)
99
+ payload['data']
100
+ else
101
+ raw = request.params['data']
102
+ raw ? JSON.parse(raw) : nil
103
+ end
104
+ rescue JSON::ParserError
105
+ nil
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,110 @@
1
+ module Rack
2
+ class ChartsApi
3
+ # Extracts chart data and options from either the query string or a JSON
4
+ # request body. Supports chartkick-compatible data formats:
5
+ #
6
+ # Single series hash: {"Jan": 10, "Feb": 20}
7
+ # Array of pairs: [["Jan", 10], ["Feb", 20]]
8
+ # Multi-series array: [{"name": "A", "data": {...}}, ...]
9
+ #
10
+ # Query-string mode (GET with short payloads):
11
+ # ?data=URL-ENCODED-JSON&type=line&w=800&h=400&title=Hello
12
+ #
13
+ # JSON body mode (POST with large payloads):
14
+ # POST body: {"data": ..., "type": "line", "options": {"w": 800}}
15
+ #
16
+ module DataParser
17
+ module_function
18
+
19
+ # Returns [chart_data, options_hash]
20
+ def parse(env)
21
+ request = Rack::Request.new(env)
22
+ params = request.params # merged GET + POST form params
23
+
24
+ if request.post? && json_content?(env)
25
+ parse_json_body(env, params)
26
+ else
27
+ parse_query(params)
28
+ end
29
+ end
30
+
31
+ def json_content?(env)
32
+ ct = env['CONTENT_TYPE'].to_s
33
+ ct.include?('application/json') || ct.include?('text/json')
34
+ end
35
+
36
+ def parse_json_body(env, params)
37
+ body = env['rack.input'].read
38
+ env['rack.input'].rewind
39
+ payload = JSON.parse(body)
40
+
41
+ chart_data = payload['data']
42
+ opts = extract_options(payload.merge(params))
43
+ [normalize_data(chart_data), opts]
44
+ rescue JSON::ParserError
45
+ [nil, {}]
46
+ end
47
+
48
+ def parse_query(params)
49
+ raw = params['data']
50
+ return [nil, {}] unless raw
51
+
52
+ chart_data = begin
53
+ JSON.parse(raw)
54
+ rescue JSON::ParserError
55
+ nil
56
+ end
57
+
58
+ [normalize_data(chart_data), extract_options(params)]
59
+ end
60
+
61
+ # Normalise chartkick-format data into a consistent internal format:
62
+ # { "series_name" => [values...], ... }
63
+ #
64
+ # This is used by PngRenderer (Gruff). HtmlRenderer passes the raw
65
+ # JSON straight through to chartkick.js which handles all formats
66
+ # natively.
67
+ def normalize_data(data)
68
+ return nil if data.nil?
69
+
70
+ case data
71
+ when Hash
72
+ # { "Jan" => 10, "Feb" => 20 } -- single series hash
73
+ data
74
+ when Array
75
+ if data.first.is_a?(Hash) && data.first.key?('name')
76
+ # Multi-series: [{"name": "A", "data": [...]}, ...]
77
+ data.each_with_object({}) do |series, h|
78
+ name = series['name'] || 'Series'
79
+ values = series['data']
80
+ h[name] = case values
81
+ when Hash then values.values
82
+ when Array
83
+ values.map { |v| v.is_a?(Array) ? v.last : v }
84
+ else
85
+ Array(values)
86
+ end
87
+ end
88
+ elsif data.first.is_a?(Array)
89
+ # Array of pairs: [["Jan", 10], ["Feb", 20]]
90
+ { 'Series' => data.map(&:last) }
91
+ else
92
+ # Flat array of numbers
93
+ { 'Series' => data }
94
+ end
95
+ end
96
+ end
97
+
98
+ def extract_options(params)
99
+ {
100
+ type: params['type'] || 'line',
101
+ w: (params['w'] || params.dig('options', 'w') || 800).to_i,
102
+ h: (params['h'] || params.dig('options', 'h') || 600).to_i,
103
+ title: params['title'] || params.dig('options', 'title'),
104
+ colors: params['colors'] || params.dig('options', 'colors'),
105
+ labels: params['labels'] || params.dig('options', 'labels')
106
+ }.compact
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,75 @@
1
+ require 'json'
2
+ require 'securerandom'
3
+
4
+ module Rack
5
+ class ChartsApi
6
+ module HtmlRenderer
7
+ CHARTKICK_CDN = 'https://unpkg.com/chartkick@5.0.1/dist/chartkick.js'
8
+ CHARTJS_CDN = 'https://unpkg.com/chart.js@4/dist/chart.umd.js'
9
+ DATE_ADAPTER = 'https://unpkg.com/chartjs-adapter-date-fns@3/dist/chartjs-adapter-date-fns.bundle.js'
10
+
11
+ # Map our type names to chartkick JS class names
12
+ JS_CHART_TYPES = {
13
+ 'line' => 'LineChart',
14
+ 'bar' => 'ColumnChart',
15
+ 'column' => 'ColumnChart',
16
+ 'pie' => 'PieChart',
17
+ 'area' => 'AreaChart',
18
+ 'scatter' => 'ScatterChart',
19
+ 'side_bar' => 'BarChart',
20
+ 'stacked_bar' => 'ColumnChart',
21
+ 'dot' => 'LineChart',
22
+ 'spider' => 'LineChart'
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ # Renders a standalone HTML page containing a Chart.js chart via
28
+ # chartkick.js. Designed to be loaded in an <iframe>.
29
+ #
30
+ # @param raw_data the original parsed JSON data (any chartkick format)
31
+ # @param opts [Hash] :type, :w, :h, :title, :colors
32
+ # @return [String] HTML document
33
+ def render(raw_data, opts = {})
34
+ chart_id = "chart-#{SecureRandom.hex(4)}"
35
+ js_type = JS_CHART_TYPES.fetch(opts[:type].to_s, 'LineChart')
36
+ width = opts[:w] || 800
37
+ height = opts[:h] || 600
38
+
39
+ js_options = {}
40
+ js_options['title'] = opts[:title] if opts[:title]
41
+ js_options['colors'] = opts[:colors] if opts[:colors]
42
+ js_options['stacked'] = true if opts[:type].to_s == 'stacked_bar'
43
+
44
+ # For the JS side, pass the raw data exactly as chartkick expects.
45
+ # chartkick.js handles Hash, Array-of-pairs, and multi-series natively.
46
+ chart_data_json = raw_data.to_json
47
+ chart_options_json = js_options.to_json
48
+
49
+ <<~HTML
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <head>
53
+ <meta charset="utf-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1">
55
+ <style>
56
+ * { margin: 0; padding: 0; box-sizing: border-box; }
57
+ body { font-family: system-ui, -apple-system, sans-serif; }
58
+ ##{chart_id} { width: #{width}px; height: #{height}px; max-width: 100%; }
59
+ </style>
60
+ <script src="#{CHARTJS_CDN}"></script>
61
+ <script src="#{DATE_ADAPTER}"></script>
62
+ <script src="#{CHARTKICK_CDN}"></script>
63
+ </head>
64
+ <body>
65
+ <div id="#{chart_id}"></div>
66
+ <script>
67
+ new Chartkick.#{js_type}("#{chart_id}", #{chart_data_json}, #{chart_options_json});
68
+ </script>
69
+ </body>
70
+ </html>
71
+ HTML
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,61 @@
1
+ require 'gruff'
2
+
3
+ module Rack
4
+ class ChartsApi
5
+ module PngRenderer
6
+ CHART_TYPES = {
7
+ 'line' => Gruff::Line,
8
+ 'bar' => Gruff::Bar,
9
+ 'pie' => Gruff::Pie,
10
+ 'area' => Gruff::Area,
11
+ 'side_bar' => Gruff::SideBar,
12
+ 'stacked_bar' => Gruff::StackedBar,
13
+ 'dot' => Gruff::Dot,
14
+ 'spider' => Gruff::Spider
15
+ }.freeze
16
+
17
+ # Map chartkick type names to gruff equivalents
18
+ TYPE_ALIASES = {
19
+ 'column' => 'bar',
20
+ 'scatter' => 'line'
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ # @param data [Hash{String => Array}] normalised series data
26
+ # @param opts [Hash] :w, :h, :type, :title, :labels, :colors
27
+ # @return [String] binary PNG blob
28
+ def render(data, opts = {})
29
+ width = opts[:w] || 800
30
+ height = opts[:h] || 600
31
+ type = resolve_type(opts[:type] || 'line')
32
+
33
+ chart = type.new("#{width}x#{height}")
34
+ chart.title = opts[:title] if opts[:title]
35
+
36
+ if opts[:labels]
37
+ chart.labels = case opts[:labels]
38
+ when Hash then opts[:labels].transform_keys(&:to_i)
39
+ when Array
40
+ opts[:labels].each_with_index.to_h { |l, i| [i, l.to_s] }
41
+ else
42
+ {}
43
+ end
44
+ end
45
+
46
+ colors = opts[:colors]
47
+ data.each_with_index do |(name, points), i|
48
+ color = colors[i] if colors.is_a?(Array)
49
+ chart.data(name.to_s, Array(points), color)
50
+ end
51
+
52
+ chart.to_image.to_blob
53
+ end
54
+
55
+ def resolve_type(name)
56
+ key = TYPE_ALIASES.fetch(name.to_s.downcase, name.to_s.downcase)
57
+ CHART_TYPES.fetch(key, Gruff::Line)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ module Rack
2
+ class ChartsApi
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module Rack
2
+ class ChartsApi
3
+ VERSION = "<%= version %>"
4
+ end
5
+ end
@@ -0,0 +1,35 @@
1
+ require 'rack'
2
+ require 'json'
3
+
4
+ require_relative 'charts_api/version'
5
+ require_relative 'charts_api/data_parser'
6
+ require_relative 'charts_api/png_renderer'
7
+ require_relative 'charts_api/html_renderer'
8
+ require_relative 'charts_api/app'
9
+
10
+ module Rack
11
+ # Rack middleware that intercepts requests to a chart endpoint and returns
12
+ # either a PNG image or an HTML page with an interactive Chart.js chart.
13
+ #
14
+ # # config.ru or Rails application.rb
15
+ # use Rack::ChartsApi
16
+ # use Rack::ChartsApi, path: "/charts" # custom mount path
17
+ #
18
+ class ChartsApi
19
+ DEFAULT_PATH = '/charts'
20
+
21
+ def initialize(app, path: DEFAULT_PATH)
22
+ @app = app
23
+ @path = path.chomp('/')
24
+ @charts_app = App.new
25
+ end
26
+
27
+ def call(env)
28
+ if env['PATH_INFO']&.start_with?(@path)
29
+ @charts_app.call(env)
30
+ else
31
+ @app.call(env)
32
+ end
33
+ end
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-charts-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nathan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: gruff
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.23'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.23'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rack-test
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: A mountable Rack app that accepts chartkick-compatible data via URL params
83
+ or JSON request body and returns either a server-rendered PNG (via Gruff) or an
84
+ HTML page with a Chart.js chart suitable for iframe embedding.
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - lib/rack/charts_api.rb
90
+ - lib/rack/charts_api/app.rb
91
+ - lib/rack/charts_api/data_parser.rb
92
+ - lib/rack/charts_api/html_renderer.rb
93
+ - lib/rack/charts_api/png_renderer.rb
94
+ - lib/rack/charts_api/version.rb
95
+ - lib/rack/charts_api/version.rb.erb
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.7.2
114
+ specification_version: 4
115
+ summary: Rack middleware that serves PNG and HTML charts from query params or JSON
116
+ body
117
+ test_files: []