Skip to content

SDKs

Ruby SDK

Convert spreadsheets, CSV and JSON into SQL from Ruby with the intosql gem - upload, wait, download, webhooks and errors.

View as Markdown

The intosql gem wraps the IntoSQL API for Ruby, one of the official SDKs. One call uploads a file, starts the conversion and waits for the result. The gem has no dependencies and uses the standard library's net/http, json and openssl.

Pro: The API is part of IntoSQL Pro. Make an API key in Settings, API keys and keep it out of your source code.

Install

Terminal
gem install intosql

Or add it to your Gemfile with bundle add intosql. The gem runs on Ruby 3.1 or newer.

Create a client

Ruby
require "intosql"

intosql = IntoSQL::Client.new(api_key: ENV.fetch("INTOSQL_API_KEY"))

The constructor raises ArgumentError if api_key is empty or nil.

Option Type Default What it does
api_key: String required An API key from Settings, API keys. Sent as Authorization: Bearer <key>.
base_url: String https://intosql.com/api/v1 The API's base address. A trailing slash is removed.
max_retries: Integer 2 Attempts after the first one for rate limits, server errors and lost connections. 0 turns retries off.
timeout: Numeric 60 Seconds to connect, send and read each answer, for the default transport.
transport: object IntoSQL::NetHTTPTransport What sends every request, the upload included. Useful for proxies and tests. See Transport.
sleeper: #call(seconds) Kernel.sleep What pauses between retries and while waiting. Tests pass a lambda that records the pause instead.
clock: #call the monotonic clock What wait reads to know when its timeout has passed.

The client never sends your key to another origin. If a pagination link points somewhere other than base_url's origin, the call raises IntoSQL::Error instead. Every request carries User-Agent: intosql-ruby/<version>.

Transport

A transport is any object with call(method:, url:, headers:, body:) that returns an IntoSQL::Response (status, headers with lowercase names, body as a string) and raises IntoSQL::ConnectionError when the connection fails. body is nil, a JSON string, or the IO of an upload.

Ruby
class Recorder
  def call(method:, url:, headers:, body: nil)
    IntoSQL::Response.new(status: 200, headers: {}, body: '{"data":{"limit":1,"used":0,"left":1,"period_start":"2026-10-01T00:00:00+00:00"}}')
  end
end

IntoSQL::Client.new(api_key: "test", transport: Recorder.new).usage.left # => 1

Convert a file

convert(file, spec:, name: nil, bytes: nil) does the whole flow and returns the finished conversion.

Ruby
require "intosql"

intosql = IntoSQL::Client.new(api_key: ENV.fetch("INTOSQL_API_KEY"))

conversion = intosql.convert("ventas.xlsx", spec: IntoSQL.spec(dialect: "postgresql", table: "ventas"))

puts conversion.report[:rows][:converted], conversion.output.url

What happens, in order:

  1. Open. POST /conversions with name, the file's size in bytes and spec. The answer has an upload address.
  2. Upload. The file is sent with a PUT to that address, with the headers the API gave, as it is. Nothing is read into memory first.
  3. Start. POST /conversions/{id}/start.
  4. Wait. The conversion is read until its status is done or failed.

file is a path (String or Pathname), opened and closed for you, or an IO such as a File or StringIO, streamed from where it stands. The size comes from the file or the IO's size; an IO without one, such as a pipe or a socket, needs bytes: with its exact size, since the conversion only starts once the whole file has arrived. To upload a string of bytes, wrap it in a StringIO.

Ruby
require "net/http"

uri = URI("https://example.com/exports/pedidos.json")

Net::HTTP.get_response(uri) do |response|
  reader, writer = IO.pipe
  Thread.new do
    response.read_body { |chunk| writer.write(chunk) }
    writer.close
  end

  intosql.convert(
    reader,
    name: "pedidos.json",
    bytes: Integer(response["Content-Length"]),
    spec: IntoSQL.spec(dialect: "sqlite")
  )
end

name helps tell the file's format, and the downloaded result is named after it: ventas.xlsx downloads as ventas.sql. It defaults to the file's base name, and is required for an IO without a path. Set table in the spec to choose the table's name.

A MySQL or MariaDB dump (.sql, .sql.gz) converts to PostgreSQL like any file. job_report reads the report with each of its tables:

Ruby
conversion = intosql.convert("shop.sql.gz", spec: IntoSQL.spec(dialect: "postgresql"))

conversion.job_report.tables.each do |table|
  puts "#{table.name}: #{table.rows} rows"
end

For a dump, source says where it comes from, tables holds each table's rows, columns, fixes and problems, and not_converted lists the views, triggers, routines and events it did not carry. For other files, source is nil and both lists are empty.

How it waits

convert, convert_url and conversions.wait read the conversion again and again with growing pauses: half a second, then 1, 2 and 4 seconds, then every 5 seconds. They return when the status is done, raise IntoSQL::ConversionFailedError when it is failed, and IntoSQL::ConversionCanceledError when it was canceled.

Wait option Type Default What it does
timeout: Numeric 3600 (1 h) Gives up after this many seconds and raises IntoSQL::TimeoutError saying the conversion is still queued or running.
interval: Numeric growing Seconds between reads, instead of the growing pauses.

Giving up only stops the waiting. The conversion keeps running on IntoSQL's servers, and you can read it later by its id.

Ruby
conversion = intosql.convert(
  "clientes_2024.xlsx",
  spec: IntoSQL.spec(dialect: "mysql"),
  timeout: 10 * 60
)

Convert a file by URL

convert_url(url, spec:, name: nil) asks IntoSQL to download the file from a public HTTPS address, then waits the same way. There is no upload step.

Ruby
conversion = intosql.convert_url(
  "https://example.com/exports/ventas.csv",
  spec: IntoSQL.spec(dialect: "mysql")
)

It takes spec:, an optional name: (otherwise the name comes from the server's Content-Disposition header or the address), and the wait options. The address must be HTTPS and reachable from the internet; anything else is refused with invalid_url. If the download fails, the conversion ends as failed.

Download the result

When status is done, output.url downloads the result. The address works for ten minutes. After that, read the conversion again to get a fresh one.

Ruby
require "net/http"

fresh = intosql.conversions.retrieve(conversion.id)
uri = URI(fresh.output.url)

Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request_get(uri) do |response|
    File.open("ventas.sql", "wb") { |file| response.read_body { |chunk| file.write(chunk) } }
  end
end

Send no Authorization header to output.url; the address carries its own signature. Files are deleted 24 hours after the conversion finishes, or right away with conversions.delete.

Build a spec

IntoSQL.spec(...) returns a full conversion spec, a hash with symbol keys, with IntoSQL's defaults. Only dialect: is required.

Ruby
request = IntoSQL.spec(
  dialect: "sql_server",
  table: "clientes",
  schema: "ventas",
  sheet: "Clientes 2024",
  batch_rows: 1000
)
Option Type Default Spec field
dialect: "postgresql", "mysql", "mariadb", "sqlite", "sql_server", "oracle" required output.dialect
format: "sql", "csv", "json" "sql" output.format
table: String or nil chosen by the engine output.table
schema: String or nil none output.schema
sheet: String or nil first sheet input.sheet
create_table: true or false true output.create_table
batch_rows: Integer 500 output.batch_rows

The rest is fixed by the helper: format, encoding, delimiter and header row detected from the file; column names in snake_case; INSERT statements (style: "inserts"); types inferred from the first 10,000 rows; date order detected; leading zeros kept; no per-column overrides. For anything else (bulk output, a date order, a locale, columns renamed, kept as text or left out), change the hash it returns or write the spec yourself. Every field is in Conversion spec.

Ruby
request = IntoSQL.spec(dialect: "postgresql", table: "pedidos")

request[:output][:style] = "bulk"
request[:inference][:date_order] = "day_first"
request[:columns] = [{ index: 0, as_text: true }]

Conversions

intosql.conversions exposes each step on its own, for when you want to upload from somewhere else, start later, or not wait.

Method Request Returns
create(name:, bytes:, spec:) POST /conversions Conversion with upload
upload(conversion, file, bytes: nil) PUT to conversion.upload.url nil
start(id) POST /conversions/{id}/start Conversion, queued
create_from_url(url:, spec:, name: nil) POST /conversions with url Conversion, queued
retrieve(id) GET /conversions/{id} Conversion
wait(id, timeout:, interval:) GET /conversions/{id}, repeated Conversion, done
list(limit: nil, status: nil) GET /conversions, every page Enumerator of Conversion
cancel(id) POST /conversions/{id}/cancel Conversion, canceled
delete(id) DELETE /conversions/{id} nil
create_case(id, consent:, share: nil, email: nil, note: nil) POST /conversions/{id}/cases Case

Step by step

Ruby
opened = intosql.conversions.create(
  name: "ventas.xlsx",
  bytes: File.size("ventas.xlsx"),
  spec: IntoSQL.spec(dialect: "mariadb")
)

intosql.conversions.upload(opened, "ventas.xlsx")
intosql.conversions.start(opened.id)

# Later, or from a webhook handler:
conversion = intosql.conversions.retrieve(opened.id)

The upload address from create works for an hour. upload takes the same files as convert and raises IntoSQL::Error if the conversion has no upload address (one read with retrieve never does), and IntoSQL::APIError with code upload_failed if storage doesn't answer with a 2xx.

List

list returns a lazy Enumerator over every conversion on the account, newest first. It fetches a page at a time (50, or limit: from 1 to 100) and follows links.next until there are no more pages. status: keeps only conversions in that status.

Ruby
intosql.conversions.list(status: "failed").each do |conversion|
  puts conversion.id, conversion.error&.code, conversion.name
end

Stop early with break, first(n) or lazy; no further pages are fetched.

Cancel

cancel(id) stops a conversion that hasn't ended and deletes its files. It stays listed as canceled and doesn't count toward the month. Canceling it again answers the same; canceling one that already ended raises IntoSQL::APIError with code already_finished.

Ruby
intosql.conversions.cancel(conversion.id)

Send us a conversion

When a file didn't convert well, or at all, create_case sends the conversion to IntoSQL so support for it gets added. Pass consent: true only when the person agreed to send it; anything else raises ArgumentError. share: "file" sends the file as well as the report; a failed conversion keeps its upload until it expires, so the file is still there to send.

Ruby
intosql.conversions.create_case(
  conversion.id,
  consent: true,
  share: "file",
  note: "Exported from our ERP; the dates come out wrong."
)

Cases are private, kept 90 days, and then deleted. The privacy policy says how they are handled.

Delete

delete(id) removes the conversion's input and output files now instead of after 24 hours. Reading it afterwards answers not_found.

Ruby
intosql.conversions.delete(conversion.id)

Usage

usage returns the bytes the plan includes this month, used and left.

Ruby
usage = intosql.usage

puts usage.limit, usage.used, usage.left, usage.period_start

Failed conversions don't count toward used.

Verify webhooks

With a webhook address saved in Settings, API keys, IntoSQL sends a signed POST when a conversion is done, failed or canceled. IntoSQL.parse_webhook checks the IntoSQL-Signature header against the raw body and returns the typed event; it raises IntoSQL::SignatureError when the signature doesn't match.

Ruby
class IntoSQLWebhooksController < ApplicationController
  skip_forgery_protection

  def create
    event = IntoSQL.parse_webhook(
      request.raw_post,
      request.headers["IntoSQL-Signature"].to_s,
      ENV.fetch("INTOSQL_WEBHOOK_SECRET")
    )

    if event.type == "conversion.done"
      # event.data is the conversion; event.id is the same on every retry.
    end

    head :no_content
  rescue IntoSQL::SignatureError
    head :bad_request
  end
end

IntoSQL.verify_webhook(body, header, secret) does the check alone and returns true or false. Both refuse a call when the header is malformed, the signature doesn't match, or the timestamp is more than 300 seconds from now. They take tolerance: to change that window and now: (Unix seconds) to fix the current time in tests. Pass the body exactly as received, before any JSON parsing. The signatures are compared in constant time. The payload and retries are in Webhooks.

Errors

Everything the gem raises on purpose inherits from IntoSQL::Error.

  • IntoSQL::APIError: the API answered with an error. It has status (the HTTP status), code (stable between versions), message (for people), fields (per field messages when the code is invalid_request, otherwise {}) and request_id, to quote when you write to us. If the answer has no error body, code is http_error.
  • IntoSQL::ConversionFailedError: the conversion ended as failed. It has id, code (stable: ours, such as timed_out, or the engine's, such as encrypted_file), problems (what the engine found) and the explanation as its message.
  • IntoSQL::ConversionCanceledError: the conversion was canceled while it was awaited. It has id.
  • IntoSQL::TimeoutError: waiting passed timeout:. It has id and the last status.
  • IntoSQL::ConnectionError: the connection kept failing after the retries. The network error is its cause.
  • IntoSQL::SignatureError: a webhook call's signature doesn't match.
Ruby
begin
  intosql.convert("ventas.xlsx", spec: IntoSQL.spec(dialect: "oracle"))
rescue IntoSQL::ConversionFailedError => e
  warn "Conversion #{e.id} failed (#{e.code}): #{e.message}"
rescue IntoSQL::APIError => e
  if e.code == "over_quota"
    warn "This month's conversions are used up."
  else
    warn [e.status, e.code, e.message, e.fields].inspect
  end
end

Every code, its status and how to fix it is in Errors.

Retries and idempotency

The client retries a request when the connection fails, when the API answers rate_limited, too_many_active or request_in_progress, on any 5xx, and on 408 and 425 from the network in between, up to max_retries times (2 by default). It waits for Retry-After when the answer has it, and otherwise half a second, then 1 second, doubling up to 8 seconds.

Every POST gets an Idempotency-Key header with a random UUID, and its retries reuse the same key. If a first attempt reached the API but its answer was lost, the retry gets that first answer back instead of opening a second conversion.

Not retried: the file upload to storage (a failed PUT raises upload_failed), already_started (the conversion is on its way; read it instead), and any other 4xx such as invalid_spec or over_quota.

Types

The answers are structs with a reader for each field. spec and report stay hashes with symbol keys, as the API sends them; job_report reads report as a JobReport. Fields the API adds later are ignored, and statuses or codes the gem doesn't know arrive as plain strings.

Type What it is
IntoSQL::Conversion A conversion as the API answers it: id, status, name, bytes, spec, report, error, output, upload, created_at, started_at, finished_at, expires_at.
IntoSQL::ConversionFailure A failed conversion's error: code, message, problems.
IntoSQL::Output A done conversion's output: bytes, url.
IntoSQL::UploadTarget A new conversion's upload: http_method (PUT), url, headers.
IntoSQL::Case A conversion sent to IntoSQL: id, conversion_id, share, status, created_at, expires_at.
IntoSQL::WebhookEvent What parse_webhook returns: id, type, created_at, data (a Conversion for conversion.* events, a hash for ping).
IntoSQL::JobReport What job_report returns: version, input, dialect, table, columns, rows, fixes, problems, timings, source, tables, not_converted.
IntoSQL::TableReport One table of a dump: source_name, name, schema, rows, columns, fixes, problems.
IntoSQL::ColumnReport One column: source_name, name, sql_type, kind, precision, scale, max_chars, nullable, fixes, excluded, as_text.
IntoSQL::Fix A correction: number, code, column, row, args.
IntoSQL::Problem Something that needs a look: code, severity, column, row, args.
IntoSQL::SourceReport Where a dump comes from: dialect, version, producer, charset, databases.
IntoSQL::NotConverted An object of a dump that was not carried: kind, name.
IntoSQL::ObjectKind Constants for "view", "trigger", "procedure", "function", "event", and ALL.
IntoSQL::ConversionStatus Constants for "awaiting_upload", "queued", "running", "done", "failed", "canceled", and ALL.
IntoSQL::Usage limit, used, left, period_start.
IntoSQL::Page One page of a list: data, links (next), meta (per_page, next_cursor, prev_cursor).
IntoSQL::Response What a transport returns: status, headers, body.

Timestamps are ISO 8601 strings; parse them with Time.iso8601 when you need a Time. report is described in Conversions, and fixes in Fixes.