finlight-client 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: 708938713e1f97553c96baf010eb5d314a8cc04a9450d9555f20e73e79f66f47
4
+ data.tar.gz: e9528ef5a54995767df07e1d6a863a3a30fb5f26713520a716d6b0e0617249a5
5
+ SHA512:
6
+ metadata.gz: 2d34def24369e14979d80c40bd6f29a841cfa42df0cf0e07be6949884030205787a9179149f7df098989b4c8e27bfc31d84c9e146299bda8fda315ce0e5a443e
7
+ data.tar.gz: 28ea56c433b06ee1b9ee1c3e592349e570c9d3bf8f03d6387cb7f45444f2752453178b3c9537a3bbe61f16eb635464de4882fcebfb31c74cdc0aac18a73140ce
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 finlight.me
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # finlight-client (Ruby)
2
+
3
+ The official Ruby client for the [finlight.me](https://finlight.me) API — financial news with sentiment analysis, entity recognition, and real-time streaming.
4
+
5
+ 📚 **Full API documentation: [docs.finlight.me](https://docs.finlight.me)**
6
+
7
+ ## Features
8
+
9
+ - **REST API**: search articles, fetch single articles by link, list sources
10
+ - **Real-time streaming**: enhanced and raw article streams over WebSocket
11
+ - **Resilient by default**: request retries with exponential backoff; WebSocket auto-reconnect, keepalive with pong watchdog, proactive connection rotation, and rate-limit handling
12
+ - **Webhook support**: HMAC-SHA256 signature verification with replay protection
13
+ - **Minimal dependencies**: stdlib `Net::HTTP` and `OpenSSL`, plus the pure-Ruby `websocket-driver`
14
+
15
+ Requires Ruby 3.2+.
16
+
17
+ ## Installation
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "finlight-client"
22
+ ```
23
+
24
+ Or directly:
25
+
26
+ ```bash
27
+ gem install finlight-client
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```ruby
33
+ require "finlight/client"
34
+
35
+ client = Finlight::Client.new(api_key: ENV["FINLIGHT_API_KEY"])
36
+
37
+ response = client.articles.fetch_articles(query: "nvidia", page_size: 10)
38
+ response.articles.each { |article| puts "[#{article.source}] #{article.title}" }
39
+ ```
40
+
41
+ ## REST API
42
+
43
+ ### Search articles
44
+
45
+ See the [query language reference](https://docs.finlight.me) for the full `query` syntax. Parameters are snake_case keywords; they map 1:1 to the camelCase API fields.
46
+
47
+ ```ruby
48
+ response = client.articles.fetch_articles(
49
+ query: '(ticker:AAPL OR ticker:NVDA) AND "Elon Musk"',
50
+ tickers: %w[AAPL NVDA],
51
+ from: "2025-01-01",
52
+ include_content: true,
53
+ include_entities: true,
54
+ order_by: "publishDate",
55
+ order: "DESC",
56
+ page_size: 20
57
+ )
58
+
59
+ response.articles.each do |article|
60
+ puts "#{article.publish_date} #{article.title} (#{article.sentiment})"
61
+ end
62
+ ```
63
+
64
+ ### Fetch a single article by link
65
+
66
+ ```ruby
67
+ article = client.articles.fetch_article_by_link(
68
+ link: "https://www.reuters.com/technology/example",
69
+ include_content: true
70
+ )
71
+ ```
72
+
73
+ ### List sources
74
+
75
+ ```ruby
76
+ sources = client.sources.get_sources
77
+ defaults = sources.select(&:is_default_source)
78
+ ```
79
+
80
+ ## Real-time streaming
81
+
82
+ `connect` blocks and reconnects automatically until you call `stop`; use `connect_async` to run it on a background thread. The enhanced stream delivers articles with sentiment and entities; the raw stream (`client.raw_websocket`) delivers unenriched articles with minimal latency.
83
+
84
+ ```ruby
85
+ websocket = client.websocket
86
+ websocket.connect(tickers: %w[AAPL NVDA], extended: true) do |article|
87
+ puts article.title
88
+ end
89
+ ```
90
+
91
+ Custom stream options:
92
+
93
+ ```ruby
94
+ websocket = client.websocket(
95
+ takeover: true, # take over an existing connection for the same key
96
+ on_close: ->(code, reason) { puts "closed: #{code} #{reason}" }
97
+ )
98
+ thread = websocket.connect_async(query: "bitcoin") { |article| puts article.title }
99
+ # ...
100
+ websocket.stop
101
+ thread.join
102
+ ```
103
+
104
+ If the server permanently rejects the connection (close code 1008), `connect` raises `Finlight::BlockedError` — reconnecting will not help; contact support.
105
+
106
+ ## Webhooks
107
+
108
+ Verify incoming webhooks with the raw (unparsed) request body:
109
+
110
+ ```ruby
111
+ # e.g. in a Rails controller
112
+ def webhook
113
+ article = Finlight::WebhookService.construct_event(
114
+ request.raw_post,
115
+ request.headers["X-Webhook-Signature"],
116
+ ENV["WEBHOOK_SECRET"],
117
+ request.headers["X-Webhook-Timestamp"]
118
+ )
119
+ Rails.logger.info("New article: #{article.title}")
120
+ head :ok
121
+ rescue Finlight::WebhookVerificationError
122
+ head :bad_request
123
+ end
124
+ ```
125
+
126
+ ## Configuration
127
+
128
+ | Option | Default | Description |
129
+ | -------------- | ------------------------- | ----------------------------------------------- |
130
+ | `base_url:` | `https://api.finlight.me` | REST endpoint |
131
+ | `wss_url:` | `wss://wss.finlight.me` | WebSocket endpoint |
132
+ | `timeout:` | 5 | Per-request and connect timeout in seconds |
133
+ | `retry_count:` | 3 | Total attempts for retryable failures (429/5xx) |
134
+ | `logger:` | stderr, WARN | Any stdlib-compatible `Logger` |
135
+
136
+ ```ruby
137
+ client = Finlight::Client.new(
138
+ api_key: api_key,
139
+ timeout: 10,
140
+ retry_count: 5,
141
+ logger: Logger.new($stdout, level: Logger::INFO)
142
+ )
143
+ ```
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ bundle install
149
+ bundle exec rake # run the test suite
150
+ ```
151
+
152
+ ### Verification against the real API
153
+
154
+ The `local/` directory is gitignored and holds smoke runners, mirroring the sibling clients — keep credentials in the environment, never in code.
155
+
156
+ ```bash
157
+ # One-shot smoke: REST endpoints + 30s article stream
158
+ FINLIGHT_API_KEY=sk_... bundle exec ruby local/smoke.rb
159
+
160
+ # REST integration tests (auto-skipped when the key is not set)
161
+ FINLIGHT_API_KEY=sk_... bundle exec rspec spec/integration
162
+ ```
163
+
164
+ Optional: `FINLIGHT_BASE_URL` / `FINLIGHT_WSS_URL` to target dev.
165
+
166
+ ## License
167
+
168
+ [MIT](LICENSE)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Finlight
8
+ # Performs authenticated REST requests with retry and exponential backoff
9
+ # (500ms * 2^(attempt-1)), mirroring the sibling clients: retries on 429 and
10
+ # transient 5xx.
11
+ class ApiClient
12
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
13
+ BASE_RETRY_DELAY = 0.5
14
+
15
+ def initialize(config, logger: Logging.default)
16
+ @config = config
17
+ @logger = logger
18
+ end
19
+
20
+ # @return [Object] the decoded JSON response
21
+ def get(path, query = {})
22
+ uri = URI.parse(@config.base_url + path)
23
+ uri.query = URI.encode_www_form(query) unless query.empty?
24
+ perform(uri, Net::HTTP::Get.new(uri))
25
+ end
26
+
27
+ # @return [Object] the decoded JSON response
28
+ def post(path, body)
29
+ uri = URI.parse(@config.base_url + path)
30
+ request = Net::HTTP::Post.new(uri)
31
+ request["Content-Type"] = "application/json"
32
+ request.body = JSON.generate(body)
33
+ perform(uri, request)
34
+ end
35
+
36
+ private
37
+
38
+ def perform(uri, request)
39
+ request["X-API-KEY"] = @config.api_key
40
+ request["User-Agent"] = Client::CLIENT_VERSION
41
+
42
+ attempt = 0
43
+ loop do
44
+ attempt += 1
45
+ response = execute(uri, request)
46
+ status = response.code.to_i
47
+ return decode(response.body.to_s) if (200..299).cover?(status)
48
+
49
+ if RETRYABLE_STATUSES.include?(status) && attempt < @config.retry_count
50
+ delay = BASE_RETRY_DELAY * (2**(attempt - 1))
51
+ @logger.warn do
52
+ "finlight: retrying request (status=#{status}, " \
53
+ "attempt=#{attempt}/#{@config.retry_count}, delay=#{(delay * 1000).round}ms)"
54
+ end
55
+ sleep(delay)
56
+ next
57
+ end
58
+
59
+ raise ApiError.new(status, response.body.to_s)
60
+ end
61
+ end
62
+
63
+ def execute(uri, request)
64
+ Net::HTTP.start(
65
+ uri.hostname, uri.port,
66
+ use_ssl: uri.scheme == "https",
67
+ open_timeout: @config.timeout,
68
+ read_timeout: @config.timeout,
69
+ write_timeout: @config.timeout
70
+ ) { |http| http.request(request) }
71
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
72
+ OpenSSL::SSL::SSLError, SystemCallError, IOError => e
73
+ raise Error, "finlight: request failed: #{e.message}"
74
+ end
75
+
76
+ def decode(body)
77
+ JSON.parse(body)
78
+ rescue JSON::ParserError => e
79
+ raise Error, "finlight: cannot decode response: #{e.message}"
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Fetches financial news articles.
5
+ class ArticleService
6
+ def initialize(api_client)
7
+ @api_client = api_client
8
+ end
9
+
10
+ # Searches articles with advanced filtering by query, tickers, sources,
11
+ # dates, countries and categories.
12
+ #
13
+ # @example
14
+ # response = client.articles.fetch_articles(
15
+ # query: "Nvidia", tickers: ["NVDA"], include_content: true, page_size: 20
16
+ # )
17
+ # response.articles.each { |a| puts a.title }
18
+ #
19
+ # @param params [Hash] snake_case keywords, e.g. +query:+, +sources:+,
20
+ # +exclude_sources:+, +from:+, +to:+, +language:+, +tickers:+,
21
+ # +include_entities:+, +exclude_empty_content:+, +include_content:+,
22
+ # +order_by:+ ("publishDate"|"createdAt"|"revisedDate"),
23
+ # +order:+ ("ASC"|"DESC"), +page_size:+ (1-1000), +page:+, +countries:+,
24
+ # +categories:+
25
+ # @return [ArticleResponse]
26
+ # @raise [ApiError] if the API request fails
27
+ def fetch_articles(**params)
28
+ response = @api_client.post("/v2/articles", Params.normalize(params))
29
+ ArticleResponse.from_h(response)
30
+ end
31
+
32
+ # Fetches a single article by its URL.
33
+ #
34
+ # @example
35
+ # article = client.articles.fetch_article_by_link(
36
+ # link: "https://www.reuters.com/technology/example", include_content: true
37
+ # )
38
+ #
39
+ # @param link [String] the URL of the article to fetch
40
+ # @param params [Hash] optional +include_content:+ and +include_entities:+
41
+ # @return [Article]
42
+ # @raise [ApiError] if the API request fails or the article is not found
43
+ def fetch_article_by_link(link:, **params)
44
+ query = Params.normalize(params.merge(link: link))
45
+ query = query.transform_values { |v| [true, false].include?(v) ? v.to_s : v }
46
+ response = @api_client.get("/v2/articles/by-link", query)
47
+ Article.from_h(response.fetch("article"))
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ class Client
5
+ VERSION = "0.1.0"
6
+
7
+ # Identifier sent as User-Agent (REST) and x-client-version (WebSocket).
8
+ CLIENT_VERSION = "ruby/finlight-client@#{VERSION}".freeze
9
+ end
10
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client/version"
4
+ require_relative "errors"
5
+ require_relative "logging"
6
+ require_relative "config"
7
+ require_relative "flex_time"
8
+ require_relative "params"
9
+ require_relative "models"
10
+ require_relative "api_client"
11
+ require_relative "article_service"
12
+ require_relative "source_service"
13
+ require_relative "webhook_service"
14
+ require_relative "websocket/base_client"
15
+ require_relative "websocket/article_client"
16
+ require_relative "websocket/raw_article_client"
17
+
18
+ module Finlight
19
+ # Entry point to the finlight API.
20
+ #
21
+ # @example
22
+ # client = Finlight::Client.new(api_key: ENV["FINLIGHT_API_KEY"])
23
+ # response = client.articles.fetch_articles(query: "Nvidia", page_size: 20)
24
+ # response.articles.each { |article| puts article.title }
25
+ class Client
26
+ attr_reader :config, :articles, :sources
27
+
28
+ # Remaining keywords (+base_url:+, +wss_url:+, +timeout:+ in seconds,
29
+ # +retry_count:+) are forwarded to {Config}.
30
+ #
31
+ # @param api_key [String] your finlight API key
32
+ def initialize(api_key:, logger: Logging.default, **)
33
+ @config = Config.new(api_key: api_key, **)
34
+ @logger = logger
35
+ api_client = ApiClient.new(@config, logger: logger)
36
+ @articles = ArticleService.new(api_client)
37
+ @sources = SourceService.new(api_client)
38
+ end
39
+
40
+ # A streaming client for enriched articles — see
41
+ # {BaseWebSocketClient#connect} for options and usage.
42
+ #
43
+ # @return [ArticleWebSocketClient]
44
+ def websocket(**)
45
+ ArticleWebSocketClient.new(@config, logger: @logger, **)
46
+ end
47
+
48
+ # A streaming client for raw articles (minimal latency, no enrichment).
49
+ #
50
+ # @return [RawArticleWebSocketClient]
51
+ def raw_websocket(**)
52
+ RawArticleWebSocketClient.new(@config, logger: @logger, **)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Connection settings shared by the REST and WebSocket clients.
5
+ class Config
6
+ DEFAULT_BASE_URL = "https://api.finlight.me"
7
+ DEFAULT_WSS_URL = "wss://wss.finlight.me"
8
+ DEFAULT_TIMEOUT = 5
9
+ DEFAULT_RETRY_COUNT = 3
10
+
11
+ attr_reader :api_key, :base_url, :wss_url, :timeout, :retry_count
12
+
13
+ # @param api_key [String] your finlight API key
14
+ # @param base_url [String] REST endpoint
15
+ # @param wss_url [String] WebSocket endpoint
16
+ # @param timeout [Numeric] per-request and connect timeout in seconds
17
+ # @param retry_count [Integer] total attempts for retryable failures (429/5xx)
18
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, wss_url: DEFAULT_WSS_URL,
19
+ timeout: DEFAULT_TIMEOUT, retry_count: DEFAULT_RETRY_COUNT)
20
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
21
+
22
+ @api_key = api_key
23
+ @base_url = base_url.chomp("/")
24
+ @wss_url = wss_url.chomp("/")
25
+ @timeout = timeout
26
+ @retry_count = retry_count
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Base class for all errors raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the API responds with a non-success status code.
8
+ class ApiError < Error
9
+ attr_reader :status_code, :body
10
+
11
+ def initialize(status_code, body)
12
+ @status_code = status_code
13
+ @body = body
14
+ super("finlight: API request failed with status #{status_code}: #{body}")
15
+ end
16
+ end
17
+
18
+ # Raised when the streaming server permanently rejects the connection
19
+ # (close code 1008). Reconnecting will not help; contact support.
20
+ class BlockedError < Error
21
+ def initialize(msg = "finlight: connection rejected by server (blocked)")
22
+ super
23
+ end
24
+ end
25
+
26
+ # Raised when webhook signature or timestamp verification fails.
27
+ class WebhookVerificationError < Error; end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Finlight
6
+ # Parses the timestamp formats used by the finlight API: RFC 3339 with or
7
+ # without zone, space-separated, and date-only. Zone-less timestamps are
8
+ # interpreted as UTC, matching the sibling clients.
9
+ module FlexTime
10
+ module_function
11
+
12
+ # @param value [String, Time, nil]
13
+ # @return [Time, nil] the parsed time in UTC, or nil for nil input
14
+ # @raise [Finlight::Error] if the value matches none of the known formats
15
+ def parse(value)
16
+ return nil if value.nil?
17
+ return value.utc if value.is_a?(Time)
18
+
19
+ s = value.to_s.strip
20
+ iso_like = s.include?(" ") ? s.sub(" ", "T") : s
21
+ DateTime.iso8601(iso_like).to_time.utc
22
+ rescue ArgumentError, TypeError
23
+ raise Error, "finlight: cannot parse timestamp: #{value.inspect}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Finlight
6
+ # Default logger shared by the clients: warnings and errors to stderr.
7
+ # Pass your own +logger:+ to the client or WebSocket clients to override.
8
+ module Logging
9
+ module_function
10
+
11
+ def default
12
+ @default ||= Logger.new($stderr, level: Logger::WARN, progname: "finlight")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Shared parsing helpers for wire-format (camelCase) JSON hashes.
5
+ module ModelParsing
6
+ module_function
7
+
8
+ # The API sometimes delivers numbers as strings (e.g. "confidence":"0.9").
9
+ def to_float(value)
10
+ return nil if value.nil?
11
+
12
+ Float(value)
13
+ rescue ArgumentError, TypeError
14
+ nil
15
+ end
16
+
17
+ def to_string_list(value)
18
+ value&.map(&:to_s)
19
+ end
20
+ end
21
+
22
+ # A ticker listing on an exchange.
23
+ class Listing
24
+ attr_reader :ticker, :exchange_code, :exchange_country
25
+
26
+ def initialize(ticker:, exchange_code:, exchange_country:)
27
+ @ticker = ticker
28
+ @exchange_code = exchange_code
29
+ @exchange_country = exchange_country
30
+ end
31
+
32
+ def self.from_h(hash)
33
+ new(
34
+ ticker: hash["ticker"],
35
+ exchange_code: hash["exchangeCode"],
36
+ exchange_country: hash["exchangeCountry"]
37
+ )
38
+ end
39
+ end
40
+
41
+ # A company tagged in an article.
42
+ class Company
43
+ attr_reader :company_id, :confidence, :country, :exchange, :industry, :sector,
44
+ :name, :ticker, :isin, :openfigi, :primary_listing, :isins, :other_listings
45
+
46
+ def initialize(company_id:, name:, ticker:, confidence: nil, country: nil, exchange: nil,
47
+ industry: nil, sector: nil, isin: nil, openfigi: nil, primary_listing: nil,
48
+ isins: nil, other_listings: nil)
49
+ @company_id = company_id
50
+ @confidence = confidence
51
+ @country = country
52
+ @exchange = exchange
53
+ @industry = industry
54
+ @sector = sector
55
+ @name = name
56
+ @ticker = ticker
57
+ @isin = isin
58
+ @openfigi = openfigi
59
+ @primary_listing = primary_listing
60
+ @isins = isins
61
+ @other_listings = other_listings
62
+ end
63
+
64
+ def self.from_h(hash)
65
+ new(
66
+ company_id: hash["companyId"],
67
+ confidence: ModelParsing.to_float(hash["confidence"]),
68
+ country: hash["country"],
69
+ exchange: hash["exchange"],
70
+ industry: hash["industry"],
71
+ sector: hash["sector"],
72
+ name: hash["name"],
73
+ ticker: hash["ticker"],
74
+ isin: hash["isin"],
75
+ openfigi: hash["openfigi"],
76
+ primary_listing: hash["primaryListing"] && Listing.from_h(hash["primaryListing"]),
77
+ isins: ModelParsing.to_string_list(hash["isins"]),
78
+ other_listings: hash["otherListings"]&.map { |l| Listing.from_h(l) }
79
+ )
80
+ end
81
+ end
82
+
83
+ # An unenriched article as delivered by the raw stream.
84
+ class RawArticle
85
+ attr_reader :link, :title, :publish_date, :source, :language, :summary, :images,
86
+ :created_at, :revised_date, :is_update, :categories
87
+
88
+ def initialize(link:, title:, publish_date:, source:, language:, summary: nil, images: nil,
89
+ created_at: nil, revised_date: nil, is_update: nil, categories: nil, **)
90
+ @link = link
91
+ @title = title
92
+ @publish_date = publish_date
93
+ @source = source
94
+ @language = language
95
+ @summary = summary
96
+ @images = images
97
+ @created_at = created_at
98
+ @revised_date = revised_date
99
+ @is_update = is_update
100
+ @categories = categories
101
+ end
102
+
103
+ def self.base_attributes(hash)
104
+ {
105
+ link: hash["link"],
106
+ title: hash["title"],
107
+ publish_date: FlexTime.parse(hash["publishDate"]),
108
+ source: hash["source"],
109
+ language: hash["language"],
110
+ summary: hash["summary"],
111
+ images: ModelParsing.to_string_list(hash["images"]),
112
+ created_at: FlexTime.parse(hash["createdAt"]),
113
+ revised_date: FlexTime.parse(hash["revisedDate"]),
114
+ is_update: hash["isUpdate"],
115
+ categories: ModelParsing.to_string_list(hash["categories"])
116
+ }
117
+ end
118
+
119
+ def self.from_h(hash)
120
+ new(**base_attributes(hash))
121
+ end
122
+ end
123
+
124
+ # An enriched article with sentiment, entities and content.
125
+ class Article < RawArticle
126
+ attr_reader :sentiment, :confidence, :content, :companies, :countries
127
+
128
+ def initialize(sentiment: nil, confidence: nil, content: nil, companies: nil,
129
+ countries: nil, **base)
130
+ super(**base)
131
+ @sentiment = sentiment
132
+ @confidence = confidence
133
+ @content = content
134
+ @companies = companies
135
+ @countries = countries
136
+ end
137
+
138
+ def self.from_h(hash)
139
+ new(
140
+ **base_attributes(hash),
141
+ sentiment: hash["sentiment"],
142
+ confidence: ModelParsing.to_float(hash["confidence"]),
143
+ content: hash["content"],
144
+ companies: hash["companies"]&.map { |c| Company.from_h(c) },
145
+ countries: ModelParsing.to_string_list(hash["countries"])
146
+ )
147
+ end
148
+ end
149
+
150
+ # A page of search results.
151
+ class ArticleResponse
152
+ attr_reader :status, :page, :page_size, :articles
153
+
154
+ def initialize(status:, page:, page_size:, articles:)
155
+ @status = status
156
+ @page = page
157
+ @page_size = page_size
158
+ @articles = articles
159
+ end
160
+
161
+ def self.from_h(hash)
162
+ new(
163
+ status: hash["status"],
164
+ page: hash["page"],
165
+ page_size: hash["pageSize"],
166
+ articles: (hash["articles"] || []).map { |a| Article.from_h(a) }
167
+ )
168
+ end
169
+ end
170
+
171
+ # A news source available through the API.
172
+ class Source
173
+ attr_reader :domain, :is_content_available, :is_default_source
174
+
175
+ def initialize(domain:, is_content_available:, is_default_source:)
176
+ @domain = domain
177
+ @is_content_available = is_content_available
178
+ @is_default_source = is_default_source
179
+ end
180
+
181
+ def self.from_h(hash)
182
+ new(
183
+ domain: hash["domain"],
184
+ is_content_available: hash["isContentAvailable"] == true,
185
+ is_default_source: hash["isDefaultSource"] == true
186
+ )
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Converts idiomatic snake_case Ruby keyword arguments into the camelCase
5
+ # JSON wire format, omitting unset (nil) fields. Keys that are already
6
+ # camelCase pass through unchanged.
7
+ module Params
8
+ module_function
9
+
10
+ # @param params [Hash]
11
+ # @return [Hash{String => Object}]
12
+ def normalize(params)
13
+ params.each_with_object({}) do |(key, value), out|
14
+ next if value.nil?
15
+
16
+ out[camelize(key)] = value
17
+ end
18
+ end
19
+
20
+ # page_size -> pageSize, exclude_sources -> excludeSources, from -> from
21
+ def camelize(key)
22
+ key.to_s.gsub(/_([a-z0-9])/) { Regexp.last_match(1).upcase }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Lists the news sources available through the API.
5
+ class SourceService
6
+ def initialize(api_client)
7
+ @api_client = api_client
8
+ end
9
+
10
+ # @return [Array<Source>] all available news sources
11
+ # @raise [ApiError] if the API request fails
12
+ def get_sources # rubocop:disable Naming/AccessorMethodName -- mirrors the sibling clients' API
13
+ @api_client.get("/v2/sources").map { |s| Source.from_h(s) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Finlight
7
+ # Securely receives and verifies webhook events from finlight: HMAC-SHA256
8
+ # signature verification with replay attack protection.
9
+ module WebhookService
10
+ SIGNATURE_PREFIX = "sha256="
11
+ REPLAY_TOLERANCE_SECONDS = 5 * 60
12
+
13
+ module_function
14
+
15
+ # Constructs and verifies a webhook event from raw request data.
16
+ #
17
+ # @example Rack/Rails endpoint
18
+ # article = Finlight::WebhookService.construct_event(
19
+ # request.raw_post,
20
+ # request.headers["X-Webhook-Signature"],
21
+ # ENV["WEBHOOK_SECRET"],
22
+ # request.headers["X-Webhook-Timestamp"]
23
+ # )
24
+ #
25
+ # @param raw_body [String] the raw, unparsed request body
26
+ # @param signature [String] the X-Webhook-Signature header (with or
27
+ # without the "sha256=" prefix)
28
+ # @param endpoint_secret [String] your webhook secret from the dashboard
29
+ # @param timestamp [String, nil] the X-Webhook-Timestamp header; when
30
+ # given it is included in the signed message and checked against a
31
+ # 5-minute replay tolerance
32
+ # @return [Article] the verified and parsed article
33
+ # @raise [WebhookVerificationError] if verification fails
34
+ def construct_event(raw_body, signature, endpoint_secret, timestamp = nil)
35
+ timestamp = nil if timestamp.to_s.empty?
36
+ normalized = signature.to_s.delete_prefix(SIGNATURE_PREFIX)
37
+
38
+ message = timestamp ? "#{timestamp}.#{raw_body}" : raw_body
39
+ expected = compute_signature(message, endpoint_secret)
40
+ raise WebhookVerificationError, "Invalid webhook signature" unless secure_compare(normalized, expected)
41
+
42
+ verify_timestamp(timestamp) if timestamp
43
+
44
+ parse_payload(raw_body)
45
+ end
46
+
47
+ def compute_signature(payload, secret)
48
+ OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
49
+ end
50
+
51
+ def verify_timestamp(timestamp)
52
+ begin
53
+ webhook_time = FlexTime.parse(timestamp)
54
+ rescue Error
55
+ raise WebhookVerificationError, "Invalid timestamp format"
56
+ end
57
+
58
+ return unless (Time.now.utc - webhook_time).abs > REPLAY_TOLERANCE_SECONDS
59
+
60
+ raise WebhookVerificationError, "Webhook timestamp outside allowed tolerance"
61
+ end
62
+
63
+ def parse_payload(raw_body)
64
+ data = begin
65
+ JSON.parse(raw_body)
66
+ rescue JSON::ParserError
67
+ raise WebhookVerificationError, "Invalid JSON payload"
68
+ end
69
+
70
+ begin
71
+ Article.from_h(data)
72
+ rescue StandardError => e
73
+ raise WebhookVerificationError, "Invalid article data: #{e.message}"
74
+ end
75
+ end
76
+
77
+ def secure_compare(left, right)
78
+ return false unless left.bytesize == right.bytesize
79
+
80
+ OpenSSL.fixed_length_secure_compare(left, right)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Streams enriched articles (sentiment, entities, content). Duplicate
5
+ # deliveries are suppressed via a small cache of recent article links.
6
+ class ArticleWebSocketClient < BaseWebSocketClient
7
+ private
8
+
9
+ def websocket_url
10
+ @config.wss_url
11
+ end
12
+
13
+ def parse_article(data)
14
+ Article.from_h(data)
15
+ end
16
+
17
+ def article_id(article)
18
+ article.link
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "socket"
7
+ require "uri"
8
+ require "websocket/driver"
9
+
10
+ module Finlight
11
+ # Shared implementation of the finlight streaming protocol: reconnect loop
12
+ # with exponential backoff, application-level ping/pong with watchdog,
13
+ # proactive connection rotation before the server-side lifetime cap, and
14
+ # optional duplicate suppression.
15
+ #
16
+ # Use the concrete {ArticleWebSocketClient} and {RawArticleWebSocketClient}
17
+ # obtained from {Client#websocket} and {Client#raw_websocket}.
18
+ class BaseWebSocketClient
19
+ RECENT_ARTICLE_CACHE_SIZE = 10
20
+ CLOSE_GRACE_SECONDS = 5
21
+ DIAL_RATE_LIMIT_BACKOFF_SECONDS = 60
22
+ ERROR_RATE_LIMIT_BACKOFF_SECONDS = 60
23
+ ERROR_BLOCKED_BACKOFF_SECONDS = 3600
24
+ DEFAULT_ADMIN_KICK_RETRY_MS = 900_000
25
+
26
+ # Close codes of the finlight WebSocket protocol.
27
+ CLOSE_BLOCKED = 1008
28
+ CLOSE_PROACTIVE_ROTATION = 4000
29
+ CLOSE_RATE_LIMITED = 4001
30
+ CLOSE_USER_BLOCKED = 4002
31
+ CLOSE_ADMIN_KICK = 4003
32
+
33
+ # Adapter between websocket-driver and the raw socket.
34
+ class DriverSocket
35
+ attr_reader :url
36
+
37
+ def initialize(url, io)
38
+ @url = url
39
+ @io = io
40
+ end
41
+
42
+ def write(data)
43
+ @io.write(data)
44
+ rescue StandardError
45
+ # The connection is going down; the read loop notices independently.
46
+ end
47
+ end
48
+
49
+ # @param config [Config]
50
+ # @param ping_interval [Numeric] seconds between application-level pings
51
+ # @param pong_timeout [Numeric] seconds without a pong before forcing a reconnect
52
+ # @param base_reconnect_delay [Numeric] initial reconnect backoff in seconds
53
+ # @param max_reconnect_delay [Numeric] backoff cap in seconds
54
+ # @param connection_lifetime [Numeric] seconds before proactively rotating
55
+ # the connection (server caps connections at 2h)
56
+ # @param takeover [Boolean] take over an existing connection for the same key
57
+ # @param on_close [#call, nil] callback invoked with (code, reason) whenever
58
+ # a connection closes
59
+ def initialize(config, ping_interval: 25, pong_timeout: 60,
60
+ base_reconnect_delay: 0.5, max_reconnect_delay: 10.0,
61
+ connection_lifetime: 115 * 60, takeover: false, on_close: nil,
62
+ logger: Logging.default)
63
+ @config = config
64
+ @ping_interval = ping_interval
65
+ @pong_timeout = pong_timeout
66
+ @base_reconnect_delay = base_reconnect_delay
67
+ @max_reconnect_delay = max_reconnect_delay
68
+ @connection_lifetime = connection_lifetime
69
+ @takeover = takeover
70
+ @on_close = on_close
71
+ @logger = logger
72
+ @stop = false
73
+ @running = false
74
+ @recent_articles = []
75
+ end
76
+
77
+ # Connects to the stream and blocks, yielding every received article.
78
+ # Reconnects automatically (exponential backoff, honors server-mandated
79
+ # wait times) until {#stop} is called or the server preempts the
80
+ # connection in favor of a newer one.
81
+ #
82
+ # @param params [Hash] stream filters as snake_case keywords, e.g.
83
+ # +query:+, +sources:+, +exclude_sources:+, +language:+, +tickers:+,
84
+ # +extended:+, +include_entities:+, +exclude_empty_content:+,
85
+ # +countries:+, +categories:+, +include_updates:+
86
+ # @yield [article] every article received on the stream
87
+ # @raise [BlockedError] if the server permanently rejected the connection
88
+ # (close code 1008)
89
+ def connect(**params, &on_article)
90
+ raise ArgumentError, "finlight: connect requires a block" unless on_article
91
+ raise Error, "finlight: connect is already running on this client" if @running
92
+
93
+ @running = true
94
+ @stop = false
95
+ @reconnect_at = nil
96
+ payload = Params.normalize(params)
97
+ delay = @base_reconnect_delay
98
+
99
+ begin
100
+ until @stop
101
+ @logger.info { "finlight: connecting to #{websocket_url}" }
102
+ result = run_connection(payload, on_article)
103
+ raise BlockedError if result == :blocked
104
+ break if @stop
105
+
106
+ delay = @base_reconnect_delay if result == :connected
107
+
108
+ now = monotonic
109
+ if @reconnect_at && @reconnect_at > now
110
+ wait = @reconnect_at - now
111
+ @logger.info { "finlight: waiting #{wait.round(1)}s until server-mandated reconnect time" }
112
+ else
113
+ wait = delay
114
+ @logger.info { "finlight: reconnecting in #{wait.round(1)}s" }
115
+ delay = [delay * 2, @max_reconnect_delay].min
116
+ end
117
+ interruptible_sleep(wait)
118
+ end
119
+ ensure
120
+ @running = false
121
+ end
122
+ nil
123
+ end
124
+
125
+ # Runs {#connect} on a background thread and returns it. Joining the
126
+ # thread re-raises {BlockedError} if the stream ended that way.
127
+ #
128
+ # @return [Thread]
129
+ def connect_async(**params, &on_article)
130
+ Thread.new do
131
+ Thread.current.report_on_exception = false
132
+ connect(**params, &on_article)
133
+ end
134
+ end
135
+
136
+ # Stops the stream: the current connection closes and the reconnect loop
137
+ # ends. Safe to call from any thread; {#connect} returns shortly after.
138
+ def stop
139
+ @stop = true
140
+ nil
141
+ end
142
+
143
+ private
144
+
145
+ # @return [String] the full WebSocket URL to connect to
146
+ def websocket_url
147
+ raise NotImplementedError
148
+ end
149
+
150
+ # @return [Object] the parsed article for a sendArticle payload
151
+ def parse_article(data)
152
+ raise NotImplementedError
153
+ end
154
+
155
+ # Identifier used for duplicate suppression, or nil to disable it.
156
+ def article_id(_article)
157
+ nil
158
+ end
159
+
160
+ def run_connection(payload, on_article)
161
+ uri = URI.parse(websocket_url)
162
+ begin
163
+ io = dial(uri)
164
+ rescue StandardError => e
165
+ @logger.error { "finlight: connection failed: #{e.message}" }
166
+ return :failed
167
+ end
168
+
169
+ begin
170
+ run_stream(io, payload, on_article)
171
+ ensure
172
+ begin
173
+ io.close
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+
180
+ def dial(uri)
181
+ host = uri.host
182
+ secure = %w[wss https].include?(uri.scheme)
183
+ port = uri.port || (secure ? 443 : 80)
184
+ tcp = Socket.tcp(host, port, connect_timeout: @config.timeout)
185
+ return tcp unless secure
186
+
187
+ context = OpenSSL::SSL::SSLContext.new
188
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
189
+ context.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
190
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
191
+ ssl.hostname = host
192
+ ssl.sync_close = true
193
+ ssl.connect
194
+ ssl.post_connection_check(host)
195
+ ssl
196
+ end
197
+
198
+ def run_stream(io, payload, on_article)
199
+ state = {
200
+ opened: false, closed: nil, error: nil, stop_sent: false,
201
+ last_pong: monotonic, next_ping: nil, abort_at: nil,
202
+ rotate_at: monotonic + @connection_lifetime
203
+ }
204
+ nonce = SecureRandom.uuid
205
+
206
+ # The server reads these headers case-sensitively in exact lowercase.
207
+ driver = WebSocket::Driver.client(DriverSocket.new(websocket_url, io))
208
+ driver.set_header("x-api-key", @config.api_key)
209
+ driver.set_header("x-client-version", Client::CLIENT_VERSION)
210
+ driver.set_header("x-takeover", "true") if @takeover
211
+
212
+ driver.on(:open) do
213
+ state[:opened] = true
214
+ state[:last_pong] = monotonic
215
+ state[:next_ping] = monotonic + @ping_interval
216
+ @reconnect_at = nil
217
+ @logger.info { "finlight: connected" }
218
+ driver.text(JSON.generate(payload.merge("clientNonce" => nonce)))
219
+ end
220
+ driver.on(:message) { |event| handle_message(event.data, driver, state, nonce, on_article) }
221
+ driver.on(:close) { |event| state[:closed] ||= [event.code || 1006, event.reason.to_s] }
222
+ driver.on(:error) { |event| handle_driver_error(event.message.to_s, state) }
223
+
224
+ queue = Thread::Queue.new
225
+ reader = Thread.new do
226
+ Thread.current.report_on_exception = false
227
+ begin
228
+ loop { queue << io.readpartial(16_384) }
229
+ rescue StandardError
230
+ queue << :eof
231
+ end
232
+ end
233
+
234
+ driver.start
235
+ pump(driver, queue, state)
236
+
237
+ close_code, close_reason = state[:closed] || [1006, state[:error] || "connection lost"]
238
+ @logger.info { "finlight: connection closed: #{close_code} - #{close_reason}" }
239
+ notify_close(close_code, close_reason)
240
+
241
+ return :blocked if close_code == CLOSE_BLOCKED
242
+
243
+ state[:opened] ? :connected : :failed
244
+ ensure
245
+ reader&.kill
246
+ end
247
+
248
+ def pump(driver, queue, state)
249
+ until state[:closed]
250
+ break if state[:error] && !state[:opened]
251
+
252
+ if @stop
253
+ break unless state[:opened]
254
+
255
+ request_close(driver, state, "client stopped", 1000)
256
+ end
257
+
258
+ chunk = queue.pop(timeout: 0.2)
259
+ if chunk == :eof
260
+ state[:closed] ||= [1006, "connection lost"]
261
+ break
262
+ end
263
+ driver.parse(chunk) if chunk
264
+
265
+ now = monotonic
266
+ if state[:opened]
267
+ if state[:next_ping] && now >= state[:next_ping]
268
+ @logger.debug { "finlight: sending ping" }
269
+ driver.text(JSON.generate({ "action" => "ping", "t" => wallclock_millis }))
270
+ state[:next_ping] = now + @ping_interval
271
+ end
272
+ if now - state[:last_pong] > @pong_timeout
273
+ @logger.warn { "finlight: no pong received in time, forcing reconnect" }
274
+ break
275
+ end
276
+ if now >= state[:rotate_at]
277
+ @logger.info { "finlight: proactive rotation before server connection cap" }
278
+ state[:rotate_at] = Float::INFINITY
279
+ request_close(driver, state, "Proactive rotation", CLOSE_PROACTIVE_ROTATION)
280
+ end
281
+ end
282
+ break if state[:abort_at] && now >= state[:abort_at]
283
+ end
284
+ end
285
+
286
+ def handle_message(data, driver, state, nonce, on_article)
287
+ msg = begin
288
+ JSON.parse(data)
289
+ rescue JSON::ParserError => e
290
+ @logger.error { "finlight: cannot parse message: #{e.message}" }
291
+ nil
292
+ end
293
+ return unless msg.is_a?(Hash)
294
+
295
+ case msg["action"]
296
+ when "pong"
297
+ ping_time = msg["t"]
298
+ if ping_time
299
+ @logger.debug { "finlight: pong received (rtt=#{wallclock_millis - ping_time.to_i}ms)" }
300
+ else
301
+ @logger.debug { "finlight: pong received" }
302
+ end
303
+ state[:last_pong] = monotonic
304
+ when "admit"
305
+ @logger.info { "finlight: admitted (leaseId=#{msg["leaseId"]})" }
306
+ server_nonce = msg["clientNonce"].to_s
307
+ if !server_nonce.empty? && server_nonce != nonce
308
+ @logger.warn { "finlight: nonce mismatch: expected #{nonce}, got #{server_nonce}" }
309
+ end
310
+ when "preempted"
311
+ @logger.warn do
312
+ "finlight: connection preempted: #{msg.fetch("reason", "unknown")} " \
313
+ "(new lease: #{msg["newLeaseId"]})"
314
+ end
315
+ @stop = true
316
+ request_close(driver, state, "Preempted by server", 1000)
317
+ when "sendArticle"
318
+ handle_article(msg["data"] || {}, on_article)
319
+ when "admin_kick"
320
+ retry_after_ms = msg.fetch("retryAfter", DEFAULT_ADMIN_KICK_RETRY_MS).to_i
321
+ @reconnect_at = monotonic + (retry_after_ms / 1000.0)
322
+ @logger.warn { "finlight: admin kick - retry after #{retry_after_ms}ms" }
323
+ request_close(driver, state, "Admin kick", CLOSE_ADMIN_KICK)
324
+ when "error"
325
+ raw = msg["data"] || msg["error"] || ""
326
+ text = raw.is_a?(String) ? raw : JSON.generate(raw)
327
+ @logger.error { "finlight: server error: #{text}" }
328
+ if text.downcase.include?("limit")
329
+ @reconnect_at = monotonic + ERROR_RATE_LIMIT_BACKOFF_SECONDS
330
+ request_close(driver, state, "Rate limited", CLOSE_RATE_LIMITED)
331
+ elsif text.downcase.include?("blocked")
332
+ @reconnect_at = monotonic + ERROR_BLOCKED_BACKOFF_SECONDS
333
+ request_close(driver, state, "User blocked", CLOSE_USER_BLOCKED)
334
+ end
335
+ else
336
+ @logger.warn { "finlight: unknown message action: #{msg["action"]}" }
337
+ end
338
+ end
339
+
340
+ def handle_article(data, on_article)
341
+ article = begin
342
+ parse_article(data)
343
+ rescue StandardError => e
344
+ @logger.error { "finlight: cannot parse article: #{e.message}" }
345
+ nil
346
+ end
347
+ return if article.nil?
348
+
349
+ id = article_id(article)
350
+ if id
351
+ if @recent_articles.include?(id)
352
+ @logger.debug { "finlight: skipping duplicate article: #{id}" }
353
+ return
354
+ end
355
+ @recent_articles << id
356
+ @recent_articles.shift if @recent_articles.size > RECENT_ARTICLE_CACHE_SIZE
357
+ end
358
+
359
+ begin
360
+ on_article.call(article)
361
+ rescue StandardError => e
362
+ @logger.error { "finlight: article callback failed: #{e.message}" }
363
+ end
364
+ end
365
+
366
+ def handle_driver_error(message, state)
367
+ if message[/Unexpected response code: (\d+)/, 1].to_i == 429
368
+ @reconnect_at = monotonic + DIAL_RATE_LIMIT_BACKOFF_SECONDS
369
+ @logger.warn { "finlight: server rejected connection (429), backing off" }
370
+ else
371
+ @logger.error { "finlight: connection error: #{message}" }
372
+ end
373
+ state[:error] = message
374
+ end
375
+
376
+ # Sends a close frame once and arms a grace deadline in case the server
377
+ # never completes the close handshake.
378
+ def request_close(driver, state, reason, code)
379
+ return if state[:stop_sent]
380
+
381
+ state[:stop_sent] = true
382
+ state[:abort_at] = monotonic + CLOSE_GRACE_SECONDS
383
+ driver.close(reason, code)
384
+ end
385
+
386
+ def notify_close(code, reason)
387
+ return unless @on_close
388
+
389
+ begin
390
+ @on_close.call(code, reason)
391
+ rescue StandardError => e
392
+ @logger.error { "finlight: on_close callback failed: #{e.message}" }
393
+ end
394
+ end
395
+
396
+ def interruptible_sleep(seconds)
397
+ deadline = monotonic + seconds
398
+ sleep([0.05, deadline - monotonic].min) while !@stop && monotonic < deadline
399
+ end
400
+
401
+ def monotonic
402
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
403
+ end
404
+
405
+ def wallclock_millis
406
+ (Time.now.to_f * 1000).to_i
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finlight
4
+ # Streams unenriched articles with minimal latency. The raw stream delivers
5
+ # every message, including updates — no duplicate suppression.
6
+ class RawArticleWebSocketClient < BaseWebSocketClient
7
+ private
8
+
9
+ def websocket_url
10
+ "#{@config.wss_url}/raw"
11
+ end
12
+
13
+ def parse_article(data)
14
+ RawArticle.from_h(data)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "finlight/client"
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: finlight-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - finlight.me
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ - !ruby/object:Gem::Dependency
27
+ name: websocket-driver
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ description: 'Financial news with sentiment analysis, entity recognition and real-time
41
+ streaming: REST, WebSocket and webhook verification client for finlight.me.'
42
+ email:
43
+ - info@finlight.me
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - lib/finlight-client.rb
51
+ - lib/finlight/api_client.rb
52
+ - lib/finlight/article_service.rb
53
+ - lib/finlight/client.rb
54
+ - lib/finlight/client/version.rb
55
+ - lib/finlight/config.rb
56
+ - lib/finlight/errors.rb
57
+ - lib/finlight/flex_time.rb
58
+ - lib/finlight/logging.rb
59
+ - lib/finlight/models.rb
60
+ - lib/finlight/params.rb
61
+ - lib/finlight/source_service.rb
62
+ - lib/finlight/webhook_service.rb
63
+ - lib/finlight/websocket/article_client.rb
64
+ - lib/finlight/websocket/base_client.rb
65
+ - lib/finlight/websocket/raw_article_client.rb
66
+ homepage: https://finlight.me
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ homepage_uri: https://finlight.me
71
+ documentation_uri: https://docs.finlight.me
72
+ source_code_uri: https://github.com/callbk/finlight-client-ruby
73
+ changelog_uri: https://github.com/callbk/finlight-client-ruby/blob/main/CHANGELOG.md
74
+ bug_tracker_uri: https://github.com/callbk/finlight-client-ruby/issues
75
+ rubygems_mfa_required: 'true'
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.2'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: Official Ruby client for the finlight.me API
93
+ test_files: []