├── config ├── prod.exs ├── .DS_Store ├── dev.exs ├── test.exs └── config.exs ├── .formatter.exs ├── .gitignore ├── test ├── test_helper.exs ├── elixir_google_spreadsheets │ ├── authorization_test.exs │ ├── spreadsheet_range.exs │ ├── client │ │ └── limiter_test.exs │ ├── client_test.exs │ ├── spreadsheet_list_test.exs │ └── spreadsheet_test.exs └── stub_modules │ ├── consumer.ex │ └── producer.ex ├── lib ├── elixir_google_spreadsheets │ ├── exceptions.ex │ ├── spreadsheet │ │ └── supervisor.ex │ ├── client │ │ ├── supervisor.ex │ │ ├── request.ex │ │ └── limiter.ex │ ├── registry.ex │ ├── client.ex │ └── spreadsheet.ex └── elixir_google_spreadsheets.ex ├── LICENSE ├── mix.exs ├── mix.lock └── README.md /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{lib,test,config}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /config/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Voronchuk/elixir_google_spreadsheets/HEAD/config/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | deps 3 | config/service_account.json 4 | doc 5 | config/*.local.exs 6 | .editorconfig 7 | .elixir_ls 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Code.require_file "./support/test_tools.ex", __DIR__ 2 | # Code.require_file "./support/test_repo.exs", __DIR__ 3 | 4 | ExUnit.start() 5 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir_google_spreadsheets, :client, request_workers: 50 4 | 5 | if File.exists?("config/dev.local.exs") do 6 | import_config "dev.local.exs" 7 | end 8 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.AuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "fetch account access token to work with Google Spreadsheets" do 5 | assert GSS.Registry.token() 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :elixir_google_spreadsheets, 4 | spreadsheet_id: "1h85keViqbRzgTN245gEw5s9roxpaUtT7i-mNXQtT8qQ" 5 | 6 | config :elixir_google_spreadsheets, :client, request_workers: 30 7 | 8 | if File.exists?("config/test.local.exs") do 9 | import_config "test.local.exs" 10 | end 11 | -------------------------------------------------------------------------------- /test/stub_modules/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.StubModules.Consumer do 2 | def start_link(producer) do 3 | GenStage.start_link(__MODULE__, {producer, self()}) 4 | end 5 | 6 | def init({producer, owner}) do 7 | {:consumer, owner, subscribe_to: [producer]} 8 | end 9 | 10 | def handle_subscribe(:producer, _, _, state) do 11 | {:automatic, state} 12 | end 13 | 14 | def handle_events(events, _from, owner) do 15 | send(owner, {:received, events}) 16 | {:noreply, [], owner} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/stub_modules/producer.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.StubModules.Producer do 2 | use GenStage 3 | 4 | def start_link(events) do 5 | GenStage.start_link(__MODULE__, {events, self()}) 6 | end 7 | 8 | def init({events, owner}) do 9 | {:producer, {events, owner}} 10 | end 11 | 12 | def handle_demand(demand, {events, owner}) do 13 | {result, tail} = Enum.split(events, demand) 14 | 15 | send(owner, {:handled_demand, result, demand}) 16 | {:noreply, result, {tail, owner}} 17 | end 18 | 19 | def handle_call({:add, new_events}, _from, {events, owner}) do 20 | {:reply, :ok, [], {events ++ new_events, owner}} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.GoogleApiError do 2 | @moduledoc """ 3 | Raised in case non 200 response code from Google Cloud API. 4 | """ 5 | defexception [:message] 6 | end 7 | 8 | defmodule GSS.InvalidColumnIndex do 9 | @moduledoc """ 10 | Raised in case more than 255 columns is queried. 11 | """ 12 | defexception [:message] 13 | end 14 | 15 | defmodule GSS.InvalidRange do 16 | @moduledoc """ 17 | Raised in case invalid range is defined. 18 | """ 19 | defexception [:message] 20 | end 21 | 22 | defmodule GSS.InvalidInput do 23 | @moduledoc """ 24 | Raised in case invalid input params are passed 25 | """ 26 | defexception [:message] 27 | end 28 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS do 2 | @moduledoc """ 3 | Bootstrap Google Spreadsheet application. 4 | """ 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # TODO Accept also from System.fetch_env. 10 | # https://github.com/peburrows/goth#installation 11 | credentials = Application.fetch_env!(:elixir_google_spreadsheets, :json) 12 | |> Jason.decode!() 13 | scopes = ["https://www.googleapis.com/auth/spreadsheets"] 14 | source = {:service_account, credentials, scopes: scopes} 15 | 16 | children = [ 17 | {Goth, name: GSS.Goth, source: source}, 18 | {Finch, name: GSS.Finch}, 19 | {GSS.Registry, []}, 20 | {GSS.Spreadsheet.Supervisor, []}, 21 | {GSS.Client.Supervisor, []} 22 | ] 23 | 24 | Supervisor.start_link(children, strategy: :one_for_all) 25 | end 26 | 27 | @doc """ 28 | Read config settings scoped for GSS. 29 | """ 30 | @spec config(atom(), any()) :: any() 31 | def config(key, default \\ nil) do 32 | Application.get_env(:elixir_google_spreadsheets, key, default) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Vyacheslav Voronchuk 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. 22 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/spreadsheet/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Spreadsheet.Supervisor do 2 | @moduledoc """ 3 | Supervisor to keep track of initialized spreadsheet processes. 4 | """ 5 | 6 | use DynamicSupervisor 7 | 8 | @spec start_link(any()) :: {:ok, pid} 9 | def start_link(_args \\ []) do 10 | DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) 11 | end 12 | 13 | @spec init([]) :: {:ok, DynamicSupervisor.sup_flags()} 14 | def init([]) do 15 | DynamicSupervisor.init(strategy: :one_for_one) 16 | end 17 | 18 | @spec spreadsheet(String.t(), Keyword.t()) :: {:ok, pid} 19 | def spreadsheet(spreadsheet_id, opts \\ []) do 20 | with \ 21 | pid when is_pid(pid) <- GSS.Registry.spreadsheet_pid(spreadsheet_id, opts), 22 | true <- Process.alive?(pid) 23 | do 24 | {:ok, pid} 25 | else 26 | _ -> 27 | spec = %{ 28 | id: GSS.Spreadsheet, 29 | start: {GSS.Spreadsheet, :start_link, [spreadsheet_id, opts]}, 30 | shutdown: 5_000, 31 | restart: :transient, 32 | type: :worker 33 | } 34 | 35 | {:ok, pid} = DynamicSupervisor.start_child(__MODULE__, spec) 36 | :ok = GSS.Registry.new_spreadsheet(spreadsheet_id, pid, opts) 37 | {:ok, pid} 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/spreadsheet_range.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.SpreadsheetRangeTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "range/4 with one row" do 5 | test "generates range for length 14" do 6 | assert GSS.Spreadsheet.range(1, 1, 1, 14) == "A1:N1" 7 | end 8 | 9 | test "generates range for length 26" do 10 | assert GSS.Spreadsheet.range(1, 1, 1, 26) == "A1:Z1" 11 | end 12 | 13 | test "generates range for length 27" do 14 | assert GSS.Spreadsheet.range(1, 1, 1, 27) == "A1:AA1" 15 | end 16 | 17 | test "generates range for length 52" do 18 | assert GSS.Spreadsheet.range(1, 1, 1, 52) == "A1:AZ1" 19 | end 20 | 21 | test "generates range for length 53" do 22 | assert GSS.Spreadsheet.range(1, 1, 1, 53) == "A1:BA1" 23 | end 24 | 25 | test "genearates range for length 254" do 26 | assert GSS.Spreadsheet.range(1, 1, 1, 254) == "A1:IT1" 27 | end 28 | 29 | test "generates range for length 255" do 30 | assert GSS.Spreadsheet.range(1, 1, 1, 255) == "A1:IU1" 31 | end 32 | 33 | test "generates range for length 702" do 34 | assert GSS.Spreadsheet.range(1, 1, 1, 702) == "A1:ZZ1" 35 | end 36 | 37 | test "generates range for length 703" do 38 | assert GSS.Spreadsheet.range(1, 1, 1, 703) == "A1:AAA1" 39 | end 40 | 41 | test "generates range for multiple rows" do 42 | assert GSS.Spreadsheet.range(1, 10, 1, 10) == "A1:J10" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :elixir_google_spreadsheets, 7 | version: "0.4.0", 8 | elixir: "~> 1.17", 9 | description: "Elixir library to read and write data of Google Spreadsheets.", 10 | docs: [main: "GSS", extras: ["README.md"]], 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | package: package(), 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | deps: deps() 16 | ] 17 | end 18 | 19 | def package do 20 | [ 21 | name: :elixir_google_spreadsheets, 22 | files: ["lib", "mix.exs"], 23 | maintainers: ["Vyacheslav Voronchuk"], 24 | licenses: ["MIT"], 25 | links: %{"Github" => "https://github.com/Voronchuk/elixir_google_spreadsheets"} 26 | ] 27 | end 28 | 29 | def application do 30 | [ 31 | extra_applications: [ 32 | :logger, 33 | :goth, 34 | :gen_stage 35 | ], 36 | mod: {GSS, []} 37 | ] 38 | end 39 | 40 | defp deps do 41 | [ 42 | {:goth, "~> 1.4"}, 43 | {:gen_stage, "~> 1.2"}, 44 | {:finch, "~> 0.19"}, 45 | {:jason, "~> 1.4"}, 46 | {:earmark, ">= 0.0.0", only: :dev}, 47 | {:ex_doc, "~> 0.37", only: :dev, runtime: false}, 48 | {:logger_file_backend, ">= 0.0.12", only: [:dev, :test]}, 49 | {:dialyxir, "~> 1.1", only: :dev, runtime: false} 50 | ] 51 | end 52 | 53 | defp elixirc_paths(:test), do: ["lib", "test/stub_modules"] 54 | defp elixirc_paths(_), do: ["lib"] 55 | end 56 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :mongo_ecto, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:mongo_ecto, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | # 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | config :elixir_google_spreadsheets, 31 | json: "./config/service_account.json" |> File.read!() 32 | 33 | config :elixir_google_spreadsheets, 34 | max_rows_per_request: 301, 35 | default_column_from: 1, 36 | default_column_to: 26 37 | 38 | config :elixir_google_spreadsheets, :client, 39 | request_workers: 50, 40 | max_demand: 100, 41 | max_interval: :timer.minutes(1), 42 | interval: 100, 43 | result_timeout: :timer.minutes(10) 44 | 45 | import_config "#{Mix.env()}.exs" 46 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/client/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Client.Supervisor do 2 | @moduledoc """ 3 | Supervisor to keep track of initialized Client, Limiter and Request processes. 4 | """ 5 | 6 | use Supervisor 7 | alias GSS.{Client, Client.Limiter, Client.Request} 8 | 9 | @spec start_link(any()) :: {:ok, pid} 10 | def start_link(_args \\ []), do: init([]) 11 | 12 | def init([]) do 13 | config = Application.fetch_env!(:elixir_google_spreadsheets, :client) 14 | limiter_args = Keyword.take(config, [:max_demand, :interval, :max_interval]) 15 | 16 | children = [ 17 | {Client, []}, 18 | %{ 19 | id: Limiter.Writer, 20 | start: {Limiter, :start_link, 21 | [ 22 | limiter_args 23 | |> Keyword.put(:clients, [{Client, partition: :write}]) 24 | |> Keyword.put(:name, Limiter.Writer) 25 | ]} 26 | }, 27 | %{ 28 | id: Limiter.Reader, 29 | start: {Limiter, :start_link, 30 | [ 31 | limiter_args 32 | |> Keyword.put(:partition, :read) 33 | |> Keyword.put(:clients, [{Client, partition: :read}]) 34 | |> Keyword.put(:name, Limiter.Reader) 35 | ]} 36 | } 37 | ] 38 | 39 | request_workers = 40 | for num <- 1..Keyword.get(config, :request_workers, 10), 41 | {limiter, name} <- [{Limiter.Writer, Request.Write}, {Limiter.Reader, Request.Read}] do 42 | name = :"#{name}#{num}" 43 | 44 | %{ 45 | id: name, 46 | start: {Request, :start_link, [[name: name, limiters: [{limiter, max_demand: 1}]]]} 47 | } 48 | end 49 | 50 | Supervisor.start_link(children ++ request_workers, [strategy: :one_for_one, name: __MODULE__]) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/client/limiter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.Client.LimiterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GSS.Client.Limiter 5 | alias GSS.StubModules.{Producer, Consumer} 6 | 7 | setup context do 8 | {:ok, client} = Producer.start_link([]) 9 | 10 | {:ok, limiter} = 11 | GenStage.start_link( 12 | Limiter, 13 | name: context.test, 14 | clients: [client], 15 | max_demand: 3, 16 | max_interval: 500, 17 | interval: 0 18 | ) 19 | 20 | {:ok, _consumer} = Consumer.start_link(limiter) 21 | 22 | [client: client, limiter: limiter] 23 | end 24 | 25 | test "receive events in packs with limits", %{client: client} do 26 | GenStage.call(client, {:add, [1, 2, 3, 4, 5]}) 27 | 28 | assert_receive {:handled_demand, [1, 2, 3], 3} 29 | refute_receive {:handled_demand, [4, 5], 3}, 400, "error waiting limits" 30 | assert_receive {:handled_demand, [4, 5], 3}, 200 31 | assert_receive {:handled_demand, [], 3} 32 | 33 | GenStage.call(client, {:add, [6, 7, 8, 9]}) 34 | 35 | assert_receive {:handled_demand, [6, 7, 8], 3} 36 | refute_receive {:handled_demand, [9], 3}, 400, "error waiting limits" 37 | 38 | assert_receive {:handled_demand, [9], 3}, 200 39 | assert_receive {:handled_demand, [], 3} 40 | end 41 | 42 | test "receive events with limits", %{client: client} do 43 | GenStage.call(client, {:add, [1]}) 44 | assert_receive {:handled_demand, [1], 3} 45 | 46 | GenStage.call(client, {:add, [2]}) 47 | assert_receive {:handled_demand, [2], 3} 48 | 49 | GenStage.call(client, {:add, [3]}) 50 | assert_receive {:handled_demand, [3], 3} 51 | 52 | GenStage.call(client, {:add, [4]}) 53 | GenStage.call(client, {:add, [5]}) 54 | 55 | refute_receive {:handled_demand, [4, 5], 3}, 400, "error waiting limits" 56 | assert_receive {:handled_demand, [4, 5], 3}, 200 57 | end 58 | 59 | test "receive events in expired interval", %{client: client} do 60 | GenStage.call(client, {:add, [1]}) 61 | assert_receive {:handled_demand, [1], 3} 62 | Process.sleep(500) 63 | 64 | GenStage.call(client, {:add, [2]}) 65 | assert_receive {:handled_demand, [2], 3} 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/client/request.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Client.Request do 2 | @moduledoc """ 3 | Worker of Request subscribed to Limiter, call request to API and send an answer to Client. 4 | """ 5 | 6 | use GenStage 7 | require Logger 8 | 9 | alias GSS.{Client, Client.RequestParams} 10 | 11 | @type state :: :ok 12 | 13 | @type options :: [ 14 | name: atom() | nil, 15 | limiters: [{atom(), keyword()} | atom()] 16 | ] 17 | 18 | @doc """ 19 | Starts an request worker linked to the current process. 20 | Takes events from Limiter and send requests through Finch 21 | 22 | ## Options 23 | * `:name` - used for name registration as described in the "Name registration" section of the module documentation. Default is `#{ 24 | __MODULE__ 25 | }` 26 | * `:limiters` - list of limiters with max_demand options. For example `[{#{Client.Limiter}, max_demand: 1}]`. 27 | """ 28 | @spec start_link(options()) :: GenServer.on_start() 29 | def start_link(args) do 30 | GenStage.start_link(__MODULE__, args, name: args[:name] || __MODULE__) 31 | end 32 | 33 | ## Callbacks 34 | 35 | def init(args) do 36 | Logger.debug("Request init: #{inspect(args)}") 37 | {:consumer, :ok, subscribe_to: args[:limiters]} 38 | end 39 | 40 | @doc ~S""" 41 | Set the subscription to manual to control when to ask for events 42 | """ 43 | def handle_subscribe(:producer, _options, _from, state) do 44 | {:automatic, state} 45 | end 46 | 47 | @spec handle_events([Client.event()], GenStage.from(), state()) :: {:noreply, [], state()} 48 | def handle_events([{:request, from, request}], _from, state) do 49 | Logger.debug("Request handle events: #{inspect(request)}") 50 | 51 | response = send_request(request) 52 | Logger.debug("Response #{inspect(response)}") 53 | GenStage.reply(from, response) 54 | 55 | {:noreply, [], state} 56 | end 57 | 58 | @spec send_request(RequestParams.t()) :: {:ok, Finch.Response.t()} | {:error, Exception.t()} 59 | defp send_request(request) do 60 | %RequestParams{ 61 | method: method, 62 | url: url, 63 | body: body, 64 | headers: headers, 65 | options: options 66 | } = request 67 | 68 | Logger.debug("send_request #{url}") 69 | 70 | finch_request = Finch.build(method, url, headers, body) 71 | case Finch.request(finch_request, GSS.Finch, options || []) do 72 | {:ok, response} -> 73 | {:ok, response} 74 | 75 | {:error, error} -> 76 | Logger.error("Finch request error #{inspect(error)}: #{inspect(request)}") 77 | {:error, error} 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.ClientTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias GSS.{Client.RequestParams, Client} 5 | alias GSS.StubModules.Consumer 6 | 7 | describe ".request" do 8 | setup do 9 | {:ok, client} = GenStage.start_link(GSS.Client, :ok) 10 | 11 | client 12 | |> send_request(:get, 1) 13 | |> send_request(:post, 2) 14 | |> send_request(:get, 3) 15 | |> send_request(:put, 4) 16 | |> send_request(:get, 5) 17 | 18 | [client: client] 19 | end 20 | 21 | def send_request(client, method, num) do 22 | request = create_request(method, num) 23 | Task.async(fn -> GenStage.call(client, {:request, request}, 50_000) end) 24 | client 25 | end 26 | 27 | def create_request(method, num) do 28 | %RequestParams{method: method, url: "http://url/?n=#{num}"} 29 | end 30 | 31 | test "add request events to the queue and release them to read consumer", %{client: client} do 32 | {:ok, _cons} = Consumer.start_link({client, partition: :read}) 33 | 34 | assert_receive {:received, [event1, event2, event3]} 35 | request1 = create_request(:get, 1) 36 | assert {:request, _, ^request1} = event1 37 | 38 | request2 = create_request(:get, 3) 39 | assert {:request, _, ^request2} = event2 40 | 41 | request3 = create_request(:get, 5) 42 | assert {:request, _, ^request3} = event3 43 | end 44 | 45 | test "add request events to the queue and release them to write consumer", %{client: client} do 46 | {:ok, _cons} = Consumer.start_link({client, partition: :write}) 47 | 48 | assert_receive {:received, [event1, event2]} 49 | request1 = create_request(:post, 2) 50 | assert {:request, _, ^request1} = event1 51 | 52 | request2 = create_request(:put, 4) 53 | assert {:request, _, ^request2} = event2 54 | end 55 | end 56 | 57 | describe ".dispatcher_hash/1" do 58 | setup do 59 | request = %RequestParams{url: "http://localhost"} 60 | %{request: request} 61 | end 62 | 63 | test "get request to :read partition", %{request: request} do 64 | event = {:request, self(), %{request | method: :get}} 65 | assert {event, :read} == Client.dispatcher_hash(event) 66 | end 67 | 68 | test "post request to :write partition", %{request: request} do 69 | event = {:request, self(), %{request | method: :post}} 70 | assert {event, :write} == Client.dispatcher_hash(event) 71 | end 72 | 73 | test "put request to :write partition", %{request: request} do 74 | event = {:request, self(), %{request | method: :put}} 75 | assert {event, :write} == Client.dispatcher_hash(event) 76 | end 77 | 78 | test "patch request to :write partition", %{request: request} do 79 | event = {:request, self(), %{request | method: :patch}} 80 | assert {event, :write} == Client.dispatcher_hash(event) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Registry do 2 | @moduledoc """ 3 | Google spreadsheets core authorization. 4 | Automatically updates access token after expiration. 5 | """ 6 | 7 | use GenServer 8 | 9 | @typedoc """ 10 | State of Google Cloud API : 11 | %{ 12 | auth: %Goth.Token{ 13 | expires: 1453356568, 14 | token: "ya29.cALlJ4HHWRvMkYB-WsAR-CZnexE459yA7QPqKg3nei1y2T7-iqmbcgxb8XrTATNn_Blim", 15 | type: "Bearer" 16 | } 17 | } 18 | """ 19 | @type state :: map() 20 | 21 | @spec start_link(any()) :: {:ok, pid} 22 | def start_link(_args \\ []) do 23 | initial_state = %{ 24 | active_sheets: %{} 25 | } 26 | 27 | GenServer.start_link(__MODULE__, initial_state, name: __MODULE__) 28 | end 29 | 30 | @spec init(state) :: {:ok, state} 31 | def init(state) do 32 | {:ok, state} 33 | end 34 | 35 | @doc """ 36 | Get account authorization token. 37 | """ 38 | @spec token() :: String.t() 39 | def token do 40 | GenServer.call(__MODULE__, :token) 41 | end 42 | 43 | @doc """ 44 | Add or replace Google Spreadsheet in a registry. 45 | """ 46 | @spec new_spreadsheet(String.t(), pid, Keyword.t()) :: :ok 47 | def new_spreadsheet(spreadsheet_id, pid, opts \\ []) do 48 | GenServer.call(__MODULE__, {:new_spreadsheet, spreadsheet_id, pid, opts}) 49 | end 50 | 51 | @doc """ 52 | Fetch Google Spreadsheet proccess by it's id in the registry. 53 | """ 54 | @spec spreadsheet_pid(String.t(), Keyword.t()) :: pid | nil 55 | def spreadsheet_pid(spreadsheet_id, opts \\ []) do 56 | GenServer.call(__MODULE__, {:spreadsheet_pid, spreadsheet_id, opts}) 57 | end 58 | 59 | # Get account authorization token, issue new token in case old has expired. 60 | def handle_call( 61 | :token, 62 | _from, 63 | %{ 64 | auth: %{ 65 | token: token, 66 | expires: expires 67 | } 68 | } = state 69 | ) do 70 | if expires < :os.system_time(:seconds) do 71 | new_state = Map.put(state, :auth, refresh_token()) 72 | {:reply, new_state.auth.token, new_state} 73 | else 74 | {:reply, token, state} 75 | end 76 | end 77 | 78 | def handle_call(:token, _from, state) do 79 | new_state = Map.put(state, :auth, refresh_token()) 80 | {:reply, new_state.auth.token, new_state} 81 | end 82 | 83 | # Update :active_sheets registry record. 84 | def handle_call( 85 | {:new_spreadsheet, spreadsheet_id, pid, opts}, 86 | _from, 87 | %{active_sheets: active_sheets} = state 88 | ) 89 | when is_bitstring(spreadsheet_id) and is_pid(pid) do 90 | registry_id = id(spreadsheet_id, opts) 91 | new_active_sheets = Map.put(active_sheets, registry_id, pid) 92 | new_state = Map.put(state, :active_sheets, new_active_sheets) 93 | {:reply, :ok, new_state} 94 | end 95 | 96 | # Get pid of sheet in :active_sheets registry. 97 | def handle_call( 98 | {:spreadsheet_pid, spreadsheet_id, opts}, 99 | _from, 100 | %{active_sheets: active_sheets} = state 101 | ) 102 | when is_bitstring(spreadsheet_id) do 103 | registry_id = id(spreadsheet_id, opts) 104 | {:reply, Map.get(active_sheets, registry_id, nil), state} 105 | end 106 | 107 | @spec refresh_token() :: map() 108 | defp refresh_token do 109 | {:ok, token} = Goth.fetch(GSS.Goth) 110 | token 111 | end 112 | 113 | @spec id(String.t(), Keyword.t()) :: String.t() 114 | defp id(spreadsheet_id, opts) do 115 | list_name = Keyword.get(opts, :list_name) 116 | 117 | if is_bitstring(list_name) do 118 | "#{spreadsheet_id}!#{list_name}" 119 | else 120 | spreadsheet_id 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/spreadsheet_list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.SpreadsheetListTest do 2 | use ExUnit.Case, async: true 3 | 4 | @test_spreadsheet_id Application.compile_env!(:elixir_google_spreadsheets, :spreadsheet_id) 5 | @test_list "list space" 6 | @test_row1 ["1", "2", "3", "4", "5"] 7 | @test_row2 ["6", "1", "2", "3", "4", "0"] 8 | @test_row3 ["7", "7", "8"] 9 | 10 | setup context do 11 | {:ok, pid} = 12 | GSS.Spreadsheet.Supervisor.spreadsheet(@test_spreadsheet_id, 13 | name: context.test, 14 | list_name: @test_list 15 | ) 16 | 17 | unless context[:skip_cleanup] do 18 | on_exit(fn -> 19 | cleanup_table(pid) 20 | :ok = DynamicSupervisor.terminate_child(GSS.Spreadsheet.Supervisor, pid) 21 | end) 22 | end 23 | 24 | {:ok, spreadsheet: pid} 25 | end 26 | 27 | @spec cleanup_table(pid) :: :ok 28 | defp cleanup_table(pid) do 29 | if Process.alive?(pid), do: GSS.Spreadsheet.clear_row(pid, 1) 30 | if Process.alive?(pid), do: GSS.Spreadsheet.clear_row(pid, 2) 31 | if Process.alive?(pid), do: GSS.Spreadsheet.clear_row(pid, 3) 32 | if Process.alive?(pid), do: GSS.Spreadsheet.clear_row(pid, 4) 33 | :ok 34 | end 35 | 36 | @tag :skip_cleanup 37 | test "initialize new spreadsheet list process", %{spreadsheet: pid} do 38 | assert GSS.Registry.spreadsheet_pid(@test_spreadsheet_id, list_name: @test_list) == pid 39 | assert GSS.Spreadsheet.id(pid) == @test_spreadsheet_id 40 | sheets = GSS.Spreadsheet.sheets(pid) 41 | assert Map.get(sheets, @test_list) 42 | end 43 | 44 | @tag :skip_cleanup 45 | test "read total number of filled rows in list", %{spreadsheet: pid} do 46 | {:ok, result} = GSS.Spreadsheet.rows(pid) 47 | assert result == 0 48 | end 49 | 50 | @tag :skip_cleanup 51 | test "read 5 columns from the 1 row in a spreadsheet list", %{spreadsheet: pid} do 52 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 1, column_to: 5) 53 | assert result == ["", "", "", "", ""] 54 | end 55 | 56 | test "write new row lines in the end of document list", %{spreadsheet: pid} do 57 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 58 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row2) 59 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 1, column_to: 5) 60 | assert result == @test_row1 61 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 2, column_to: 6) 62 | assert result == @test_row2 63 | end 64 | 65 | test "write some lines and append row between them on list", %{spreadsheet: pid} do 66 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 67 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row2) 68 | :ok = GSS.Spreadsheet.append_row(pid, 1, @test_row3) 69 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 3, column_to: 3) 70 | assert result == @test_row3 71 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 2, column_to: 6) 72 | assert result == @test_row2 73 | end 74 | 75 | test "read batched for 2 rows in list", %{spreadsheet: pid} do 76 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5) 77 | assert result == [nil, nil] 78 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5, pad_empty: true) 79 | assert result == [["", "", "", "", ""], ["", "", "", "", ""]] 80 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 81 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5, pad_empty: true) 82 | assert result == [@test_row1, ["", "", "", "", ""]] 83 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, ["A1:E1", "A2:E2"]) 84 | assert result == [@test_row1, nil] 85 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, [1, 2], column_to: 5) 86 | assert result == [@test_row1, nil] 87 | end 88 | 89 | test "clear batched for 2 rows in list", %{spreadsheet: pid} do 90 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 91 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row1) 92 | :ok = GSS.Spreadsheet.clear_rows(pid, ["A1:E1", "A2:E2"]) 93 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5) 94 | assert result == [nil, nil] 95 | end 96 | 97 | test "write batched for 2 rows in list", %{spreadsheet: pid} do 98 | {:ok, _} = GSS.Spreadsheet.write_rows(pid, ["A2:E2", "A3:F3"], [@test_row1, @test_row2]) 99 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 2, 3, column_to: 6) 100 | assert result == [@test_row1 ++ [""], @test_row2] 101 | end 102 | 103 | @tag :skip_cleanup 104 | test "unexisting lists should gracefully fail" do 105 | {:ok, pid} = 106 | GSS.Spreadsheet.Supervisor.spreadsheet(@test_spreadsheet_id, 107 | name: :unknown_list, 108 | list_name: "unknown" 109 | ) 110 | 111 | # Wait for the process to exit, capturing the exit message. 112 | monitor_ref = Process.monitor(pid) 113 | assert_receive {:DOWN, ^monitor_ref, :process, ^pid, "sheet list not found unknown"}, 5_000 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/client/limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Client.Limiter do 2 | @moduledoc """ 3 | Model of Limiter request subscribed to Client with partition :write or :read 4 | 5 | This process is a ProducerConsumer for this GenStage pipeline. 6 | """ 7 | 8 | use GenStage 9 | require Logger 10 | 11 | @type state :: %__MODULE__{ 12 | max_demand: pos_integer(), 13 | max_interval: timeout(), 14 | producer: GenStage.from(), 15 | scheduled_at: pos_integer() | nil, 16 | taked_events: pos_integer(), 17 | interval: timeout() 18 | } 19 | defstruct [:max_demand, :max_interval, :producer, :scheduled_at, :taked_events, :interval] 20 | 21 | @type options :: [ 22 | name: atom(), 23 | max_demand: pos_integer() | nil, 24 | max_interval: timeout() | nil, 25 | interval: timeout() | nil, 26 | clients: [{atom(), keyword()} | atom()] 27 | ] 28 | 29 | @doc """ 30 | Starts an limiter manager linked to the current process. 31 | 32 | If the event manager is successfully created and initialized, the function 33 | returns {:ok, pid}, where pid is the PID of the server. If a process with the 34 | specified server name already exists, the function returns {:error, 35 | {:already_started, pid}} with the PID of that process. 36 | 37 | ## Options 38 | * `:name` - used for name registration as described in the "Name 39 | registration" section of the module documentation 40 | * `:interval` - ask new events from producer after `:interval` milliseconds. 41 | * `:max_demand` - count of maximum requests per `:maximum_interval` 42 | * `:max_interval` - maximum time that allowed in `:max_demand` requests 43 | * `:clients` - list of clients with partition options. For example `[{GSS.Client, partition: :read}}]`. 44 | """ 45 | @spec start_link(options()) :: GenServer.on_start() 46 | def start_link(options \\ []) do 47 | GenStage.start_link(__MODULE__, options, name: Keyword.get(options, :name)) 48 | end 49 | 50 | ## Callbacks 51 | 52 | def init(args) do 53 | Logger.debug("init: #{inspect(args)}") 54 | 55 | state = %__MODULE__{ 56 | max_demand: args[:max_demand] || 100, 57 | max_interval: args[:max_interval] || 1_000, 58 | interval: args[:interval] || 100, 59 | taked_events: 0, 60 | scheduled_at: nil 61 | } 62 | 63 | Process.send_after(self(), :ask, 0) 64 | 65 | {:producer_consumer, state, subscribe_to: args[:clients]} 66 | end 67 | 68 | # Set the subscription to manual to control when to ask for events 69 | def handle_subscribe(:producer, _options, from, state) do 70 | {:manual, Map.put(state, :producer, from)} 71 | end 72 | 73 | # Make the subscriptions to auto for consumers 74 | def handle_subscribe(:consumer, _, _, state) do 75 | {:automatic, state} 76 | end 77 | 78 | def handle_events(events, _from, state) do 79 | Logger.debug(fn -> "Limiter Handle events: #{inspect(events)}" end) 80 | 81 | state = 82 | state 83 | |> Map.update!(:taked_events, &(&1 + length(events))) 84 | |> schedule_counts() 85 | 86 | {:noreply, events, state} 87 | end 88 | 89 | @doc """ 90 | Gives events for the next stage to process when requested 91 | """ 92 | def handle_demand(demand, state) when demand > 0 do 93 | {:noreply, [], state} 94 | end 95 | 96 | @doc """ 97 | Ask new events if needed 98 | """ 99 | def handle_info(:ask, state) do 100 | {:noreply, [], ask_and_schedule(state)} 101 | end 102 | 103 | @doc """ 104 | Check to reach limit. 105 | 106 | If limit not reached ask again after `:interval` timeout, 107 | otherwise ask after `:max_interval` timeout. 108 | """ 109 | def ask_and_schedule(state) do 110 | cond do 111 | limited_events?(state) -> 112 | Process.send_after(self(), :ask, state.max_interval) 113 | clear_counts(state) 114 | 115 | interval_expired?(state) -> 116 | GenStage.ask(state.producer, state.max_demand) 117 | Process.send_after(self(), :ask, state.interval) 118 | clear_counts(state) 119 | 120 | true -> 121 | GenStage.ask(state.producer, state.max_demand) 122 | Process.send_after(self(), :ask, state.interval) 123 | 124 | schedule_counts(state) 125 | end 126 | end 127 | 128 | # take events more than max demand 129 | defp limited_events?(state) do 130 | state.taked_events >= state.max_demand 131 | end 132 | 133 | # check limit of interval 134 | defp interval_expired?(%__MODULE__{scheduled_at: nil}), do: false 135 | 136 | defp interval_expired?(%__MODULE__{scheduled_at: scheduled_at, max_interval: max_interval}) do 137 | now = :erlang.timestamp() 138 | :timer.now_diff(now, scheduled_at) >= max_interval * 1000 139 | end 140 | 141 | defp clear_counts(state) do 142 | %{state | taked_events: 0, scheduled_at: nil} 143 | end 144 | 145 | # set current timestamp to scheduled_at 146 | defp schedule_counts(%__MODULE__{scheduled_at: nil} = state) do 147 | %{state | scheduled_at: :erlang.timestamp()} 148 | end 149 | 150 | defp schedule_counts(state), do: state 151 | end 152 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 3 | "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 5 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 6 | "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, 7 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 8 | "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, 9 | "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, 10 | "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 13 | "logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 17 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 18 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 19 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 21 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 22 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir Google Spreadsheets 2 | Elixir library to read and write data of Google Spreadsheets. 3 | 4 | This library is based on __Google Cloud API v4__ and uses __Google Service Accounts__ to manage it's content. 5 | 6 | ## Integration with Ecto 7 | Check [ecto_gss](https://github.com/Voronchuk/ecto_gss) if you need to integrate your Google Spreadsheet with Ecto changesets for validation and other features. 8 | 9 | # Setup 10 | 1. Use [this](https://console.developers.google.com/start/api?id=sheets.googleapis.com) wizard to create or select a project in the Google Developers Console and automatically turn on the API. Click __Continue__, then __Go to credentials__. 11 | 2. On the __Add credentials to your project page__, create __Service account key__. 12 | 3. Select your project name as service account and __JSON__ as key format, download the created key and rename it to __service_account.json__. 13 | 4. Press __Manage service accounts__ on a credential page, copy your __Service Account Identifier__: _[projectname]@[domain].iam.gserviceaccount.com_ 14 | 5. Create or open existing __Google Spreadsheet document__ on your __Google Drive__ and add __Service Account Identifier__ as user invited in spreadsheet's __Collaboration Settings__. 15 | 6. Add `{:elixir_google_spreadsheets, "~> 0.4"}` to __mix.exs__ under `deps` function, add `:elixir_google_spreadsheets` in your application list. 16 | 7. Add __service_account.json__ in your `config.exs` or other config file, like `dev.exs` or `prod.secret.exs`. 17 | config :elixir_google_spreadsheets, 18 | json: "./config/service_account.json" |> File.read! 19 | 8. Run `mix deps.get && mix deps.compile`. 20 | 21 | ## Testing 22 | The [following Google Spreadsheet](https://docs.google.com/spreadsheets/d/1h85keViqbRzgTN245gEw5s9roxpaUtT7i-mNXQtT8qQ/edit?usp=sharing) is used to run tests locally, it can be copied to run local tests. 23 | 24 | ## API limits 25 | All Google API limits, suggested params are the following: 26 | 27 | ```elixir 28 | config :elixir_google_spreadsheets, :client, 29 | request_workers: 50, 30 | max_demand: 100, 31 | max_interval: :timer.minutes(1), 32 | interval: 100, 33 | result_timeout: :timer.minutes(10), 34 | request_opts: [] # See Finch request options 35 | ``` 36 | 37 | # Usage 38 | Initialise spreadsheet thread with it's id which you can fetch from URL: 39 | 40 | ```elixir 41 | {:ok, pid} = GSS.Spreadsheet.Supervisor.spreadsheet("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXXXX") 42 | ``` 43 | 44 | Or if you wish to edit only a specific list: 45 | 46 | ```elixir 47 | {:ok, pid} = GSS.Spreadsheet.Supervisor.spreadsheet( 48 | "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXXXX", 49 | list_name: "my_list3" 50 | ) 51 | ``` 52 | 53 | Sample operations: 54 | 55 | * `GSS.Spreadsheet.id(pid)` 56 | * `GSS.Spreadsheet.properties(pid)` 57 | * `GSS.Spreadsheet.get_sheet_id(pid)` 58 | * `GSS.Spreadsheet.sheets(pid)` 59 | * `GSS.Spreadsheet.rows(pid)` 60 | * `GSS.Spreadsheet.update_sheet_size(pid, 10, 5)` 61 | * `GSS.Spreadsheet.read_row(pid, 1, column_to: 5)` 62 | * `GSS.Spreadsheet.read_rows(pid, 1, 10, column_to: 5, pad_empty: true)` 63 | * `GSS.Spreadsheet.read_rows(pid, [1, 3, 5], column_to: 5, pad_empty: true)` 64 | * `GSS.Spreadsheet.read_rows(pid, ["A1:E1", "A2:E2"])` 65 | * `GSS.Spreadsheet.write_row(pid, 1, ["1", "2", "3", "4", "5"])` 66 | * `GSS.Spreadsheet.write_rows(pid, ["A2:E2", "A3:F3"], [["1", "2", "3", "4", "5"], ["1", "2", "3", "4", "5", "6"]])` 67 | * `GSS.Spreadsheet.append_row(pid, 1, ["1", "2", "3", "4", "5"])` 68 | * `GSS.Spreadsheet.append_rows(pid, 1, [["1", "2", "3", "4", "5"], ["1", "2", "3", "4", "5", "6"]])` 69 | * `GSS.Spreadsheet.clear_row(pid, 1)` 70 | * `GSS.Spreadsheet.clear_rows(pid, 1, 10)` 71 | * `GSS.Spreadsheet.clear_rows(pid, ["A1:E1", "A2:E2"])` 72 | * `GSS.Spreadsheet.set_basic_filter(pid, %{row_from: 0, row_to: 5, col_from: 1, col_to: 10}, %{col_idx: 2, condition_type: "TEXT_CONTAINS", user_entered_value: "test"})` 73 | * `GSS.Spreadsheet.set_basic_filter(pid, %{row_from: nil, row_to: nil, col_from: nil, col_to: nil}, %{})` 74 | * `GSS.Spreadsheet.clear_basic_filter(pid)` 75 | * `GSS.Spreadsheet.freeze_header(pid, %{dim: :row, n_freeze: 1})` 76 | * `GSS.Spreadsheet.freeze_header(pid, %{dim: :col, n_freeze: 2})` 77 | * `GSS.Spreadsheet.update_col_width(pid, %{col_idx: 1, col_width: 200})` 78 | * `GSS.Spreadsheet.add_number_format(pid, %{row_from: 0, row_to: nil, col_from: 3, col_to: 4}, %{type: "NUMBER", pattern: "#0.0%"})` 79 | * `GSS.Spreadsheet.update_col_wrap(pid, %{row_from: 0, row_to: nil, col_from: 5, col_to: 7}, %{wrap_strategy: "clip"})` 80 | * `GSS.Spreadsheet.set_font(pid, %{row_from: nil, row_to: nil, col_from: nil, col_to: nil}, %{font_family: "Source Code Pro"})` 81 | * `GSS.Spreadsheet.add_conditional_format(pid, %{row_from: nil, row_to: nil, col_from: nil, col_to: nil}, %{formula: "=$E1=\"TEST\"", color_map: %{red: 1, green: 0.8, blue: 0.8}})` 82 | * `GSS.Spreadsheet.update_border(pid, %{row_from: 0, row_to: 10, col_from: 2, col_to: 5}, %{top: %{red: 1, style: "dashed"}, bottom: %{green: 1, blue: 0.7}, left: %{blue: 0.8, alpha: 0.75}})` 83 | 84 | Last function param of `GSS.Spreadsheet` function calls support the same `Keyword` options (in snake_case instead of camelCase), as defined in [Google API Docs](https://developers.google.com/sheets/reference/rest/v4/spreadsheets.values). 85 | 86 | We also define `column_from` and `column_to` Keyword options which control range of cell which will be queried. 87 | 88 | Default values: 89 | * `column_from = 1` - default is configurable as `:default_column_from` 90 | * `column_to = 26` - default is configurable as `:default_column_to` 91 | * `major_dimension = "ROWS"` 92 | * `value_render_option = "FORMATTED_VALUE"` 93 | * `datetime_render_option = "FORMATTED_STRING"` 94 | * `value_render_option = "USER_ENTERED"` 95 | * `insert_data_option = "INSERT_ROWS"` 96 | 97 | # Suggestions 98 | * Recommended columns __26__ (more on your own risk), max rows in a batch __100-300__ depending on your data size per row, configurable as `:max_rows_per_request`; 99 | * __Pull requests / reports / feedback are welcome.__ 100 | -------------------------------------------------------------------------------- /test/elixir_google_spreadsheets/spreadsheet_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GSS.SpreadsheetTest do 2 | use ExUnit.Case, async: true 3 | 4 | @test_spreadsheet_id Application.compile_env!(:elixir_google_spreadsheets, :spreadsheet_id) 5 | @test_row1 ["1", "2", "3", "4", "5"] 6 | @test_row2 ["6", "1", "2", "3", "4", "0"] 7 | @test_row3 ["7", "7", "8"] 8 | @test_row4 ["1", "4", "8", "4"] 9 | 10 | setup context do 11 | {:ok, pid} = GSS.Spreadsheet.Supervisor.spreadsheet(@test_spreadsheet_id, name: context.test) 12 | 13 | on_exit(fn -> 14 | cleanup_table(pid) 15 | :ok = DynamicSupervisor.terminate_child(GSS.Spreadsheet.Supervisor, pid) 16 | end) 17 | 18 | {:ok, spreadsheet: pid} 19 | end 20 | 21 | @spec cleanup_table(pid) :: :ok 22 | defp cleanup_table(pid) do 23 | GSS.Spreadsheet.clear_row(pid, 1) 24 | GSS.Spreadsheet.clear_row(pid, 2) 25 | GSS.Spreadsheet.clear_row(pid, 3) 26 | GSS.Spreadsheet.clear_row(pid, 4) 27 | GSS.Spreadsheet.clear_row(pid, 5) 28 | end 29 | 30 | test "initialize new spreadsheet process", %{spreadsheet: pid} do 31 | assert GSS.Registry.spreadsheet_pid(@test_spreadsheet_id) == pid 32 | assert GSS.Spreadsheet.id(pid) == @test_spreadsheet_id 33 | end 34 | 35 | test "should start only one for the same id", %{spreadsheet: pid} do 36 | {:ok, pid2} = GSS.Spreadsheet.Supervisor.spreadsheet(@test_spreadsheet_id) 37 | assert pid == pid2 38 | end 39 | 40 | test "read total number of filled rows", %{spreadsheet: pid} do 41 | {:ok, result} = GSS.Spreadsheet.rows(pid) 42 | assert result == 0 43 | end 44 | 45 | test "read 5 columns from the 1 row in a spreadsheet", %{spreadsheet: pid} do 46 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 1, column_to: 5) 47 | assert result == ["", "", "", "", ""] 48 | end 49 | 50 | test "write new row lines in the end of document", %{spreadsheet: pid} do 51 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 52 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row2) 53 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 1, column_to: 5) 54 | assert result == @test_row1 55 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 2, column_to: 6) 56 | assert result == @test_row2 57 | end 58 | 59 | test "write some lines and append row between them", %{spreadsheet: pid} do 60 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 61 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row2) 62 | :ok = GSS.Spreadsheet.append_row(pid, 1, @test_row3) 63 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 3, column_to: 3) 64 | assert result == @test_row3 65 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 2, column_to: 6) 66 | assert result == @test_row2 67 | end 68 | 69 | test "write some lines and append two rows between them", %{spreadsheet: pid} do 70 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 71 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row2) 72 | :ok = GSS.Spreadsheet.append_rows(pid, 1, [@test_row3, [nil], @test_row4]) 73 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 3, column_to: 3) 74 | assert result == @test_row3 75 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 4, column_to: 1) 76 | assert result == [""] 77 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 5, column_to: 4) 78 | assert result == @test_row4 79 | {:ok, result} = GSS.Spreadsheet.read_row(pid, 2, column_to: 6) 80 | assert result == @test_row2 81 | end 82 | 83 | test "read batched for 2 rows", %{spreadsheet: pid} do 84 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5, batch_range: true) 85 | assert result == [nil, nil] 86 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5, pad_empty: true) 87 | assert result == [["", "", "", "", ""], ["", "", "", "", ""]] 88 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row1) 89 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 3, column_to: 5, pad_empty: true) 90 | assert result == [["", "", "", "", ""], @test_row1, ["", "", "", "", ""]] 91 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, ["A1:E1", "A2:E2", "A3:E3"]) 92 | assert result == [nil, @test_row1, nil] 93 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, [1, 2, 3], column_to: 5) 94 | assert result == [nil, @test_row1, nil] 95 | end 96 | 97 | test "read batched for only 1 row is possible", %{spreadsheet: pid} do 98 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 1, column_to: 5) 99 | assert result == [nil] 100 | end 101 | 102 | test "read batched for 3 rows more then 1000 from start", %{spreadsheet: pid} do 103 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1000, 1002, column_to: 5) 104 | assert result == [nil, nil, nil] 105 | :ok = GSS.Spreadsheet.write_row(pid, 1000, @test_row1) 106 | :ok = GSS.Spreadsheet.write_row(pid, 1001, @test_row1) 107 | :ok = GSS.Spreadsheet.write_row(pid, 1002, @test_row1) 108 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1000, 1002, column_to: 5, pad_empty: true) 109 | assert result == [@test_row1, @test_row1, @test_row1] 110 | GSS.Spreadsheet.clear_row(pid, 1000) 111 | GSS.Spreadsheet.clear_row(pid, 1001) 112 | GSS.Spreadsheet.clear_row(pid, 1002) 113 | end 114 | 115 | test "read batched for 250 rows", %{spreadsheet: pid} do 116 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 2, 250, column_to: 26, batch_range: true) 117 | assert length(result) == 249 118 | end 119 | 120 | test "clear batched for 2 rows", %{spreadsheet: pid} do 121 | :ok = GSS.Spreadsheet.write_row(pid, 1, @test_row1) 122 | :ok = GSS.Spreadsheet.write_row(pid, 2, @test_row1) 123 | :ok = GSS.Spreadsheet.clear_rows(pid, 1, 2, column_to: 5) 124 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 1, 2, column_to: 5) 125 | assert result == [nil, nil] 126 | end 127 | 128 | test "write batched for 2 rows", %{spreadsheet: pid} do 129 | {:ok, _} = GSS.Spreadsheet.write_rows(pid, ["A2:E2", "A3:F3"], [@test_row1, @test_row2]) 130 | {:ok, result} = GSS.Spreadsheet.read_rows(pid, 2, 3, column_to: 6) 131 | assert result == [@test_row1 ++ [""], @test_row2] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/client.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Client do 2 | @moduledoc """ 3 | Model of Client abstraction 4 | This process is a Producer for this GenStage pipeline. 5 | """ 6 | 7 | use GenStage 8 | require Logger 9 | 10 | defmodule RequestParams do 11 | @type t :: %__MODULE__{ 12 | method: atom(), 13 | url: binary(), 14 | body: binary() | iodata(), 15 | headers: [{binary(), binary()}], 16 | options: Keyword.t() 17 | } 18 | defstruct method: nil, url: nil, body: "", headers: [], options: [] 19 | end 20 | 21 | @type event :: {:request, GenStage.from(), RequestParams.t()} 22 | @type partition :: :write | :read 23 | 24 | @spec start_link(any()) :: GenServer.on_start() 25 | def start_link(_args \\ []) do 26 | GenStage.start_link(__MODULE__, :ok, name: __MODULE__) 27 | end 28 | 29 | @doc """ 30 | Issues an HTTP request with the given method to the given URL using Finch. 31 | 32 | This function is usually used indirectly by helper functions such as `get/3`, `post/4`, `put/4`, etc. 33 | 34 | ### Arguments 35 | - **`method`**: HTTP method as an atom (e.g., `:get`, `:head`, `:post`, `:put`, `:delete`, etc.). 36 | - **`url`**: Target URL as a binary string. 37 | - **`body`**: Request body, which can be a binary, char list, or iodata. Special forms include: 38 | - `{:form, [{K, V}, ...]}` – to send a form URL-encoded payload. 39 | - `{:file, "/path/to/file"}` – to send a file. 40 | - `{:stream, enumerable}` – to lazily send a stream of binaries/char lists. 41 | - **`headers`**: HTTP headers as a list of two-element tuples (e.g., `[{"Accept", "application/json"}]`). 42 | - **`options`**: A keyword list of Finch options. Supported options include: 43 | - **`:timeout`** – Timeout (in milliseconds) for establishing a connection (default is 8000). 44 | - **`:recv_timeout`** – Timeout (in milliseconds) for receiving data (default is 5000). 45 | - **`:proxy`** – A proxy for the request; either a URL or a `{host, port}` tuple. 46 | - **`:proxy_auth`** – Proxy authentication credentials as `{user, password}`. 47 | - **`:ssl`** – SSL options as supported by Erlang’s `ssl` module. 48 | - **`:follow_redirect`** – Boolean to indicate if redirects should be followed. 49 | - **`:max_redirect`** – Maximum number of redirects to follow. 50 | - **`:params`** – Enumerable of two-item tuples to be appended to the URL as query string parameters. 51 | - Any other Finch-supported options can also be provided. 52 | 53 | Timeout values can be specified as an integer or as `:infinity`. 54 | 55 | ### Returns 56 | - On success, returns `{:ok, %Finch.Response{}}`. 57 | - On failure, returns `{:error, reason}` where `reason` is an exception. 58 | 59 | ### Examples 60 | 61 | request(:post, "https://my.website.com", "{\"foo\": 3}", [{"Accept", "application/json"}]) 62 | """ 63 | @spec request(atom, binary, binary() | iodata(), [{binary(), binary()}], Keyword.t()) :: 64 | {:ok, Finch.Response.t()} | {:error, Exception.t()} 65 | def request(method, url, body \\ "", headers \\ [], options \\ []) do 66 | request = %RequestParams{ 67 | method: method, 68 | url: safe_encode_url(url), 69 | body: body, 70 | headers: maybe_parse_headers(headers), 71 | options: options 72 | } 73 | 74 | case options[:result_timeout] || config(:result_timeout) do 75 | nil -> 76 | GenStage.call(__MODULE__, {:request, request}) 77 | 78 | recv_timeout -> 79 | GenStage.call(__MODULE__, {:request, request}, recv_timeout) 80 | end 81 | end 82 | 83 | @doc """ 84 | Starts a task with request that must be awaited on. 85 | """ 86 | @spec request_async(atom, binary, binary() | iodata(), [{binary(), binary()}], Keyword.t()) :: 87 | Task.t() 88 | def request_async(method, url, body \\ "", headers \\ [], options \\ []) do 89 | Task.async(GSS.Client, :request, [method, url, body, headers, options]) 90 | end 91 | 92 | ## Callbacks 93 | 94 | def init(:ok) do 95 | dispatcer = 96 | {GenStage.PartitionDispatcher, partitions: [:write, :read], hash: &dispatcher_hash/1} 97 | 98 | {:producer, :queue.new(), dispatcher: dispatcer} 99 | end 100 | 101 | @doc """ 102 | Divide request into to partitions :read and :write 103 | """ 104 | @spec dispatcher_hash(event) :: {event, partition()} 105 | def dispatcher_hash({:request, _from, request} = event) do 106 | case request.method do 107 | :get -> {event, :read} 108 | _ -> {event, :write} 109 | end 110 | end 111 | 112 | @doc """ 113 | Adds an event to the queue 114 | """ 115 | def handle_call({:request, request}, from, queue) do 116 | updated_queue = :queue.in({:request, from, request}, queue) 117 | {:noreply, [], updated_queue} 118 | end 119 | 120 | @doc """ 121 | Gives events for the next stage to process when requested 122 | """ 123 | def handle_demand(demand, queue) when demand > 0 do 124 | {events, updated_queue} = take_from_queue(queue, demand, []) 125 | {:noreply, Enum.reverse(events), updated_queue} 126 | end 127 | 128 | @doc """ 129 | Read config settings scoped for GSS client. 130 | """ 131 | @spec config(atom(), any()) :: any() 132 | def config(key, default \\ nil) do 133 | Application.get_env(:elixir_google_spreadsheets, :client) 134 | |> Keyword.get(key, default) 135 | end 136 | 137 | # take demand events from the queue 138 | defp take_from_queue(queue, 0, events) do 139 | {events, queue} 140 | end 141 | 142 | defp take_from_queue(queue, demand, events) do 143 | case :queue.out(queue) do 144 | {{:value, {kind, from, event}}, queue} -> 145 | take_from_queue(queue, demand - 1, [{kind, from, event} | events]) 146 | 147 | {:empty, queue} -> 148 | take_from_queue(queue, 0, events) 149 | end 150 | end 151 | 152 | defp safe_encode_url(url) do 153 | # Replace spaces with %20 while leaving colons and other reserved characters intact. 154 | String.replace(url, " ", "%20") 155 | end 156 | 157 | defp maybe_parse_headers(headers) when is_list(headers), do: headers 158 | defp maybe_parse_headers(%{} = headers) do 159 | Enum.reduce(headers, [], fn {k, v}, acc -> [{k, v} | acc] end) 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/elixir_google_spreadsheets/spreadsheet.ex: -------------------------------------------------------------------------------- 1 | defmodule GSS.Spreadsheet do 2 | @moduledoc """ 3 | Model of Google Spreadsheet for external interaction. 4 | """ 5 | 6 | require Logger 7 | use GenServer 8 | alias GSS.Client 9 | 10 | @typedoc """ 11 | State of currently active Google spreadsheet: 12 | %{ 13 | spreadsheet_id => "16Wgt0fuoYDgEAtGtYKF4jdjAhZez0q77UhkKdeKI6B4", 14 | list_name => nil, 15 | sheet_id => nil 16 | } 17 | """ 18 | @type state :: map() 19 | @type spreadsheet_data :: [String.t()] 20 | @type spreadsheet_response :: {:json, map()} | {:error, Exception.t()} | no_return() 21 | @type grid_range :: %{ 22 | row_from: integer(), 23 | row_to: integer(), 24 | col_from: integer(), 25 | col_to: integer() 26 | } 27 | 28 | @api_url_spreadsheet "https://sheets.googleapis.com/v4/spreadsheets/" 29 | 30 | @max_rows GSS.config(:max_rows_per_request, 301) 31 | @default_column_from GSS.config(:default_column_from, 1) 32 | @default_column_to GSS.config(:default_column_to, 26) 33 | 34 | @spec start_link(String.t(), Keyword.t()) :: {:ok, pid} 35 | def start_link(spreadsheet_id, opts) do 36 | GenServer.start_link(__MODULE__, {spreadsheet_id, opts}, Keyword.take(opts, [:name])) 37 | end 38 | 39 | @impl true 40 | @spec init({String.t(), Keyword.t()}) :: {:ok, state} 41 | def init({spreadsheet_id, opts}) do 42 | {:ok, %{ 43 | spreadsheet_id: spreadsheet_id, 44 | sheet_id: nil, 45 | list_name: Keyword.get(opts, :list_name) 46 | }, {:continue, {:load_sheet_id, opts}}} 47 | end 48 | 49 | @impl true 50 | def handle_continue({:load_sheet_id, _opts}, %{list_name: nil} = state), do: {:noreply, state} 51 | def handle_continue({:load_sheet_id, _opts}, %{spreadsheet_id: spreadsheet_id, list_name: list_name} = state) do 52 | with {:json, %{"sheets" => sheets}} <- spreadsheet_query(:get, spreadsheet_id) do 53 | Enum.filter(sheets, fn %{"properties" => %{"title" => title}} -> title == list_name end) 54 | |> Enum.map(fn %{"properties" => %{"sheetId" => sheet_id}} -> sheet_id end) 55 | |> case do 56 | [sheet_id] -> 57 | {:noreply, Map.put(state, :sheet_id, sheet_id)} 58 | 59 | _ -> 60 | {:stop, "sheet list not found #{list_name}", state} 61 | end 62 | else 63 | {:error, exception} -> 64 | Logger.error "[#{__MODULE__}] failed to load sheet id: #{inspect(exception)}" 65 | {:stop, "failed to load sheet id", state} 66 | end 67 | end 68 | 69 | @doc """ 70 | Get spreadsheet internal id. 71 | """ 72 | @spec id(pid) :: String.t() 73 | def id(pid), do: GenServer.call(pid, :id) 74 | 75 | @doc """ 76 | Get spreadsheet properties. 77 | """ 78 | @spec properties(pid) :: map() 79 | def properties(pid), do: GenServer.call(pid, :properties) 80 | 81 | @doc """ 82 | Get sheet id associated with list_name in state. 83 | """ 84 | @spec get_sheet_id(pid) :: {:ok, integer()} 85 | def get_sheet_id(pid), do: GenServer.call(pid, :get_sheet_id) 86 | 87 | @doc """ 88 | Get spreadsheet sheets from properties. 89 | """ 90 | @spec sheets(pid, Keyword.t()) :: [map()] | map() 91 | def sheets(pid, options \\ []) do 92 | with {:ok, %{"sheets" => sheets}} <- gen_server_call(pid, :properties, options), 93 | {:is_raw_response?, false, _} <- 94 | {:is_raw_response?, Keyword.get(options, :raw, false), sheets} do 95 | Enum.reduce(sheets, %{}, fn %{"properties" => %{"title" => title} = properties}, acc -> 96 | Map.put(acc, title, properties) 97 | end) 98 | else 99 | {:is_raw_response?, false, sheets} -> 100 | sheets 101 | 102 | _ -> 103 | [] 104 | end 105 | end 106 | 107 | @doc """ 108 | Get total amount of rows in a spreadsheet. 109 | """ 110 | @spec rows(pid, Keyword.t()) :: {:ok, integer()} | {:error, Exception.t()} 111 | def rows(pid, options \\ []) do 112 | gen_server_call(pid, :rows, options) 113 | end 114 | 115 | @spec update_sheet_size(pid, integer(), integer(), Keyword.t()) :: 116 | {:ok, list()} | {:error, Exception.t()} 117 | def update_sheet_size(pid, row_count, col_count, options \\ []) do 118 | gen_server_call(pid, {:update_sheet_size, row_count, col_count, options}, options) 119 | end 120 | 121 | @doc """ 122 | Granular read by a custom range from a spreadsheet. 123 | """ 124 | @spec fetch(pid, String.t()) :: {:ok, spreadsheet_data} | {:error, Exception.t()} 125 | def fetch(pid, range) do 126 | GenServer.call(pid, {:fetch, range}) 127 | end 128 | 129 | @doc """ 130 | Read row in a spreadsheet by index. 131 | """ 132 | @spec read_row(pid, integer(), Keyword.t()) :: {:ok, spreadsheet_data} | {:error, Exception.t()} 133 | def read_row(pid, row_index, options \\ []) do 134 | gen_server_call(pid, {:read_row, row_index, options}, options) 135 | end 136 | 137 | @doc """ 138 | Override row in a spreadsheet by index. 139 | """ 140 | @spec write_row(pid, integer(), spreadsheet_data, Keyword.t()) :: :ok 141 | def write_row(pid, row_index, column_list, options \\ []) when is_list(column_list) do 142 | gen_server_call(pid, {:write_row, row_index, column_list, options}, options) 143 | end 144 | 145 | @doc """ 146 | Append row in a spreadsheet after an index. 147 | """ 148 | @spec append_row(pid, integer(), spreadsheet_data, Keyword.t()) :: :ok 149 | def append_row(pid, row_index, [cell | _] = column_list, options \\ []) when is_binary(cell) or is_nil(cell) do 150 | gen_server_call(pid, {:append_rows, row_index, [column_list], options}, options) 151 | end 152 | 153 | @doc """ 154 | Clear row in a spreadsheet by index. 155 | """ 156 | @spec clear_row(pid, integer(), Keyword.t()) :: :ok 157 | def clear_row(pid, row_index, options \\ []) do 158 | gen_server_call(pid, {:clear_row, row_index, options}, options) 159 | end 160 | 161 | @doc """ 162 | Batched read, which returns more than one record. 163 | Pass either an array of ranges (or rows), or start and end row indexes. 164 | 165 | By default it returns `nils` for an empty rows, 166 | use `pad_empty: true` and `column_to: integer` options to fill records 167 | with an empty string values. 168 | """ 169 | @spec read_rows(pid, [String.t()] | [integer()]) :: 170 | {:ok, [spreadsheet_data | nil]} | {:error, Exception.t()} 171 | def read_rows(pid, ranges), do: read_rows(pid, ranges, []) 172 | 173 | @spec read_rows(pid, [String.t()] | [integer()], Keyword.t()) :: 174 | {:ok, [spreadsheet_data]} | {:error, Exception.t()} 175 | def read_rows(pid, ranges, options) when is_list(ranges) do 176 | gen_server_call(pid, {:read_rows, ranges, options}, options) 177 | end 178 | 179 | @spec read_rows(pid, integer(), integer()) :: {:ok, [spreadsheet_data]} | {:error, atom} 180 | def read_rows(pid, row_index_start, row_index_end) 181 | when is_integer(row_index_start) and is_integer(row_index_end), 182 | do: read_rows(pid, row_index_start, row_index_end, []) 183 | 184 | def read_rows(_, _, _), 185 | do: {:error, %GSS.InvalidInput{message: "invalid start or end row index"}} 186 | 187 | @spec read_rows(pid, integer(), integer(), Keyword.t()) :: 188 | {:ok, [spreadsheet_data]} | {:error, atom} 189 | def read_rows(pid, row_index_start, row_index_end, options) 190 | when is_integer(row_index_start) and is_integer(row_index_end) and 191 | row_index_start <= row_index_end do 192 | gen_server_call(pid, {:read_rows, row_index_start, row_index_end, options}, options) 193 | end 194 | 195 | def read_rows(_, _, _, _), 196 | do: {:error, %GSS.InvalidInput{message: "invalid start or end row index"}} 197 | 198 | @doc """ 199 | Batched clear, which deletes more then one record. 200 | Pass either an array of ranges, or start and end row indexes. 201 | """ 202 | @spec clear_rows(pid, [String.t()]) :: :ok | {:error, Exception.t()} 203 | def clear_rows(pid, ranges), do: clear_rows(pid, ranges, []) 204 | @spec clear_rows(pid, [String.t()], Keyword.t()) :: :ok | {:error, Exception.t()} 205 | def clear_rows(pid, ranges, options) when is_list(ranges) do 206 | gen_server_call(pid, {:clear_rows, ranges, options}, options) 207 | end 208 | 209 | @spec clear_rows(pid, integer(), integer()) :: :ok | {:error, Exception.t()} 210 | def clear_rows(pid, row_index_start, row_index_end) 211 | when is_integer(row_index_start) and is_integer(row_index_end), 212 | do: clear_rows(pid, row_index_start, row_index_end, []) 213 | 214 | def clear_rows(_, _, _), 215 | do: {:error, %GSS.InvalidInput{message: "invalid start or end row index"}} 216 | 217 | @spec clear_rows(pid, integer(), integer(), Keyword.t()) :: :ok | {:error, Exception.t()} 218 | def clear_rows(pid, row_index_start, row_index_end, options) 219 | when is_integer(row_index_start) and is_integer(row_index_end) and 220 | row_index_start < row_index_end do 221 | gen_server_call(pid, {:clear_rows, row_index_start, row_index_end, options}, options) 222 | end 223 | 224 | def clear_rows(_, _, _, _), 225 | do: {:error, %GSS.InvalidInput{message: "invalid start or end row index"}} 226 | 227 | @doc """ 228 | Batch update to write multiple rows. 229 | 230 | Range schema should define the same amount of rows as 231 | amound of records in data and same amount of columns 232 | as entries in data record. 233 | """ 234 | @spec write_rows(pid, [String.t()], [spreadsheet_data], Keyword.t()) :: 235 | {:ok, list()} | {:error, Exception.t()} 236 | def write_rows(pid, ranges, data, opts \\ []) 237 | 238 | def write_rows(pid, ranges, data, options) 239 | when is_list(data) and is_list(ranges) and length(data) == length(ranges) do 240 | gen_server_call(pid, {:write_rows, ranges, data, options}, options) 241 | end 242 | 243 | def write_rows(_, _, _, _), 244 | do: 245 | {:error, 246 | %GSS.InvalidInput{ 247 | message: "invalid ranges or data, length of ranges and data lists should be the same" 248 | }} 249 | 250 | @doc """ 251 | Batch update to append multiple rows. 252 | """ 253 | @spec append_rows(pid, integer(), [spreadsheet_data], Keyword.t()) :: 254 | :ok | {:error, Exception.t()} 255 | def append_rows(pid, row_index, [[cell | _] | _] = data, options \\ []) 256 | when (is_binary(cell) or is_nil(cell)) and row_index > 0 do 257 | gen_server_call(pid, {:append_rows, row_index, data, options}, options) 258 | end 259 | 260 | @doc """ 261 | Set Basic Filter. 262 | 263 | Two options for `params`: 264 | - To not filter out any rows, pass an empty map. 265 | - To filter out rows based on a column value, add these 266 | keys: `col_idx`, `condition_type`, `user_entered_value`. 267 | """ 268 | @spec set_basic_filter(pid, grid_range(), map(), Keyword.t()) :: 269 | {:ok, list()} | {:error, Exception.t()} 270 | def set_basic_filter(pid, grid_range, params, opts \\ []) 271 | 272 | def set_basic_filter(pid, grid_range, params, options) do 273 | gen_server_call(pid, {:set_basic_filter, grid_range, params, options}, options) 274 | end 275 | 276 | @doc """ 277 | Clear Basic Filter. 278 | """ 279 | @spec clear_basic_filter(pid, Keyword.t()) :: {:ok, map()} | {:error, Exception.t()} 280 | def clear_basic_filter(pid, opts \\ []) 281 | 282 | def clear_basic_filter(pid, options) do 283 | gen_server_call(pid, {:clear_basic_filter, options}, options) 284 | end 285 | 286 | @doc """ 287 | Freeze Row or Column Header. 288 | 289 | Required keys in `params`: 290 | - `dim`: Either `:row` or `:col` to set the dimension to freeze. 291 | - `n_freeze`: Set the number of rows or columns to freeze. 292 | """ 293 | @spec freeze_header(pid, %{dim: :row | :col, n_freeze: integer()}, Keyword.t()) :: 294 | {:ok, list()} | {:error, Exception.t()} 295 | def freeze_header(pid, params, opts \\ []) 296 | 297 | def freeze_header(pid, params, options) do 298 | gen_server_call(pid, {:freeze_header, params, options}, options) 299 | end 300 | 301 | @doc """ 302 | Update Column Width. 303 | 304 | Required keys in `params`: 305 | - `col_idx`: Index of column. 306 | - `col_width`: Column width in pixels. 307 | """ 308 | @spec update_col_width(pid, %{col_idx: integer(), col_width: integer()}, Keyword.t()) :: 309 | {:ok, list()} | {:error, Exception.t()} 310 | def update_col_width(pid, params, opts \\ []) 311 | 312 | def update_col_width(pid, params, options) do 313 | gen_server_call(pid, {:update_col_width, params, options}, options) 314 | end 315 | 316 | @doc """ 317 | Add Number Format. 318 | 319 | Required keys in `params`: 320 | - `type`: The number format of the cell (e.g., `DATE` or `TIME`). 321 | - `pattern`: Pattern string used for formatting (e.g., `yyyy-mm-dd`). 322 | """ 323 | @spec add_number_format( 324 | pid, 325 | grid_range(), 326 | %{type: String.t(), pattern: String.t()}, 327 | Keyword.t() 328 | ) :: 329 | {:ok, list()} | {:error, Exception.t()} 330 | def add_number_format(pid, grid_range, params, opts \\ []) 331 | 332 | def add_number_format(pid, grid_range, params, options) do 333 | gen_server_call(pid, {:add_number_format, grid_range, params, options}, options) 334 | end 335 | 336 | @doc """ 337 | Update Column Wrap. 338 | 339 | Required keys in `params`: 340 | - `wrap_strategy`: How to wrap text in a cell. Options: [`overflow_cell`, `clip`, `wrap`]. 341 | """ 342 | @spec update_col_wrap(pid, grid_range(), %{wrap_strategy: String.t()}, Keyword.t()) :: 343 | {:ok, list()} | {:error, Exception.t()} 344 | def update_col_wrap(pid, grid_range, params, opts \\ []) 345 | 346 | def update_col_wrap(pid, grid_range, params, options) do 347 | gen_server_call(pid, {:update_col_wrap, grid_range, params, options}, options) 348 | end 349 | 350 | @doc """ 351 | Set Font. 352 | 353 | Required key in `params`: `font_family`. 354 | """ 355 | @spec set_font(pid, grid_range(), %{font_family: String.t()}, Keyword.t()) :: 356 | {:ok, list()} | {:error, Exception.t()} 357 | def set_font(pid, grid_range, params, opts \\ []) 358 | 359 | def set_font(pid, grid_range, params, options) do 360 | gen_server_call(pid, {:set_font, grid_range, params, options}, options) 361 | end 362 | 363 | @doc """ 364 | Add Conditional Formula using a boolean rule (as opposed to a gradient rule). 365 | 366 | Required keys in `params`: 367 | - `formula`: A value the condition is based on. The value is parsed as if the user 368 | typed into a cell. Formulas are supported (and must begin with an = or a '+'). 369 | - `color_map`: Represents a color in the RGBA color space. Keys are `red`, 370 | `green`, `blue`, and `alpha`, and each value is in the interval [0, 1]. 371 | """ 372 | @spec add_conditional_format( 373 | pid, 374 | grid_range(), 375 | %{formula: String.t(), color_map: map()}, 376 | Keyword.t() 377 | ) :: 378 | {:ok, list()} | {:error, Exception.t()} 379 | def add_conditional_format(pid, grid_range, params, opts \\ []) 380 | 381 | def add_conditional_format(pid, grid_range, params, options) do 382 | gen_server_call(pid, {:add_conditional_format, grid_range, params, options}, options) 383 | end 384 | 385 | @doc """ 386 | Update Border. 387 | 388 | Map `params` can only have keys in `[:top, :bottom, :left, :right]`. If a key is omitted, 389 | then the border remains as-is on that side. Subkeys: `style`, `red`, `green`, `blue`, `alpha`. 390 | """ 391 | @spec update_border(pid, grid_range(), map(), Keyword.t()) :: 392 | {:ok, list()} | {:error, Exception.t()} 393 | def update_border(pid, grid_range, params, opts \\ []) 394 | 395 | def update_border(pid, grid_range, params, options) do 396 | gen_server_call(pid, {:update_border, grid_range, params, options}, options) 397 | end 398 | 399 | # Get spreadsheet id stored in this state. 400 | # Used mainly for testing purposes. 401 | @impl true 402 | def handle_call(:id, _from, %{spreadsheet_id: spreadsheet_id} = state) do 403 | {:reply, spreadsheet_id, state} 404 | end 405 | 406 | # Get the spreadsheet properties 407 | def handle_call(:properties, _from, %{spreadsheet_id: spreadsheet_id} = state) do 408 | query = spreadsheet_id 409 | 410 | case spreadsheet_query(:get, query) do 411 | {:json, properties} -> 412 | {:reply, {:ok, properties}, state} 413 | 414 | {:error, exception} -> 415 | {:reply, {:error, exception}, state} 416 | end 417 | end 418 | 419 | # Get the sheet id from state. 420 | # Used mainly in Spreadsheet.Supervisor.spreadsheet/2. 421 | def handle_call(:get_sheet_id, _from, %{sheet_id: nil} = state) do 422 | {:reply, {:ok, nil}, state} 423 | end 424 | 425 | def handle_call(:get_sheet_id, _from, %{sheet_id: sheet_id} = state) do 426 | {:reply, {:ok, sheet_id}, state} 427 | end 428 | 429 | # Get total number of rows from spreadsheets. 430 | def handle_call(:rows, _from, %{spreadsheet_id: spreadsheet_id} = state) do 431 | query = "#{spreadsheet_id}/values/#{maybe_attach_list(state)}A1:B" 432 | 433 | case spreadsheet_query(:get, query) do 434 | {:json, %{"values" => values}} -> 435 | {:reply, {:ok, length(values)}, state} 436 | 437 | {:json, _} -> 438 | {:reply, {:ok, 0}, state} 439 | 440 | {:error, exception} -> 441 | {:reply, {:error, exception}, state} 442 | end 443 | end 444 | 445 | def handle_call( 446 | {:update_sheet_size, row_count, col_count, options}, 447 | _from, 448 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 449 | ) do 450 | request = %{ 451 | updateSheetProperties: %{ 452 | fields: "gridProperties", 453 | properties: %{ 454 | sheetId: sheet_id, 455 | gridProperties: %{rowCount: row_count, columnCount: col_count} 456 | } 457 | } 458 | } 459 | 460 | request_body = %{requests: [request]} 461 | batch_update_query(spreadsheet_id, request_body, options, state) 462 | end 463 | 464 | # Fetch the given range of cells from the spreadsheet 465 | def handle_call({:fetch, the_range}, _from, %{spreadsheet_id: spreadsheet_id} = state) do 466 | query = "#{spreadsheet_id}/values/#{maybe_attach_list(state)}#{the_range}" 467 | 468 | case spreadsheet_query(:get, query) do 469 | {:json, %{"values" => values}} -> 470 | {:reply, {:ok, values}, state} 471 | 472 | {:json, _} -> 473 | {:reply, {:ok, nil}, state} 474 | 475 | {:error, exception} -> 476 | {:reply, {:error, exception}, state} 477 | end 478 | end 479 | 480 | # Get column value list for specific row from a spreadsheet. 481 | def handle_call( 482 | {:read_row, row_index, options}, 483 | _from, 484 | %{spreadsheet_id: spreadsheet_id} = state 485 | ) do 486 | major_dimension = Keyword.get(options, :major_dimension, "ROWS") 487 | value_render_option = Keyword.get(options, :value_render_option, "FORMATTED_VALUE") 488 | datetime_render_option = Keyword.get(options, :datetime_render_option, "FORMATTED_STRING") 489 | 490 | column_from = Keyword.get(options, :column_from, @default_column_from) 491 | column_to = Keyword.get(options, :column_to, @default_column_to) 492 | range = range(row_index, row_index, column_from, column_to) 493 | 494 | query = 495 | "#{spreadsheet_id}/values/#{maybe_attach_list(state)}#{range}" <> 496 | "?majorDimension=#{major_dimension}&valueRenderOption=#{value_render_option}" <> 497 | "&dateTimeRenderOption=#{datetime_render_option}" 498 | 499 | case spreadsheet_query(:get, query) do 500 | {:json, %{"values" => [values]}} when length(values) >= column_to -> 501 | {:reply, {:ok, values}, state} 502 | 503 | {:json, %{"values" => [values]}} -> 504 | pad_amount = column_to - length(values) 505 | {:reply, {:ok, values ++ pad(pad_amount)}, state} 506 | 507 | {:json, _} -> 508 | {:reply, {:ok, pad(column_to)}, state} 509 | 510 | {:error, exception} -> 511 | {:reply, {:error, exception}, state} 512 | end 513 | end 514 | 515 | # Write values in a specific row to a spreadsheet. 516 | def handle_call( 517 | {:write_row, row_index, column_list, options}, 518 | _from, 519 | %{spreadsheet_id: spreadsheet_id} = state 520 | ) do 521 | value_input_option = Keyword.get(options, :value_input_option, "USER_ENTERED") 522 | 523 | write_cells_count = length(column_list) 524 | column_from = Keyword.get(options, :column_from, 1) 525 | column_to = Keyword.get(options, :column_to, column_from + write_cells_count - 1) 526 | range = range(row_index, row_index, column_from, column_to, state) 527 | query = "#{spreadsheet_id}/values/#{range}?valueInputOption=#{value_input_option}" 528 | 529 | case spreadsheet_query(:put, query, column_list, options ++ [range: range]) do 530 | {:json, %{"updatedRows" => 1, "updatedColumns" => updated_columns}} 531 | when updated_columns > 0 -> 532 | {:reply, :ok, state} 533 | 534 | {:error, exception} -> 535 | {:reply, {:error, exception}, state} 536 | end 537 | end 538 | 539 | # Insert row under some other row and write the column_list content there. 540 | def handle_call( 541 | {:append_rows, row_index, column_lists, options}, 542 | _from, 543 | %{spreadsheet_id: spreadsheet_id} = state 544 | ) do 545 | value_input_option = Keyword.get(options, :value_input_option, "USER_ENTERED") 546 | insert_data_option = Keyword.get(options, :insert_data_option, "INSERT_ROWS") 547 | 548 | write_cells_count = Enum.map(column_lists, &Kernel.length/1) |> Enum.max() 549 | row_count = length(column_lists) 550 | row_max_index = row_index + row_count - 1 551 | column_from = Keyword.get(options, :column_from, 1) 552 | column_to = Keyword.get(options, :column_to, column_from + write_cells_count - 1) 553 | range = range(row_index, row_max_index, column_from, column_to, state) 554 | 555 | query = 556 | "#{spreadsheet_id}/values/#{range}:append" <> 557 | "?valueInputOption=#{value_input_option}&insertDataOption=#{insert_data_option}" 558 | 559 | case spreadsheet_query( 560 | :post, 561 | query, 562 | column_lists, 563 | options ++ [range: range, wrap_data: false] 564 | ) do 565 | {:json, 566 | %{ 567 | "updates" => %{ 568 | "updatedRows" => updated_rows, 569 | "updatedColumns" => updated_columns 570 | } 571 | }} 572 | when updated_columns > 0 and updated_rows > 0 -> 573 | {:reply, :ok, state} 574 | 575 | {:error, exception} -> 576 | {:reply, {:error, exception}, state} 577 | end 578 | end 579 | 580 | # Clear rows in spreadsheet by their index. 581 | def handle_call( 582 | {:clear_row, row_index, options}, 583 | _from, 584 | %{spreadsheet_id: spreadsheet_id} = state 585 | ) do 586 | column_from = Keyword.get(options, :column_from, @default_column_from) 587 | column_to = Keyword.get(options, :column_to, @default_column_to) 588 | range = range(row_index, row_index, column_from, column_to) 589 | query = "#{spreadsheet_id}/values/#{maybe_attach_list(state)}#{range}:clear" 590 | 591 | case spreadsheet_query(:post, query) do 592 | {:json, %{"clearedRange" => _}} -> 593 | {:reply, :ok, state} 594 | 595 | {:error, exception} -> 596 | {:reply, {:error, exception}, state} 597 | end 598 | end 599 | 600 | # Get column value list for specific row from a spreadsheet. 601 | def handle_call({:read_rows, [row | _] = rows, options}, from, state) when is_integer(row) do 602 | column_from = Keyword.get(options, :column_from, @default_column_from) 603 | column_to = Keyword.get(options, :column_to, @default_column_to) 604 | 605 | ranges = 606 | Enum.map(rows, fn row_index -> 607 | range(row_index, row_index, column_from, column_to) 608 | end) 609 | 610 | handle_call({:read_rows, ranges, options}, from, state) 611 | end 612 | 613 | def handle_call( 614 | {:read_rows, ranges, options}, 615 | _from, 616 | %{spreadsheet_id: spreadsheet_id} = state 617 | ) do 618 | major_dimension = Keyword.get(options, :major_dimension, "ROWS") 619 | value_render_option = Keyword.get(options, :value_render_option, "FORMATTED_VALUE") 620 | datetime_render_option = Keyword.get(options, :datetime_render_option, "FORMATTED_STRING") 621 | batched_ranges = Keyword.get(options, :batched_ranges, ranges) 622 | 623 | str_ranges = 624 | batched_ranges 625 | |> Enum.map(&{:ranges, "#{maybe_attach_list(state)}#{&1}"}) 626 | |> URI.encode_query() 627 | 628 | query = 629 | "#{spreadsheet_id}/values:batchGet" <> 630 | "?majorDimension=#{major_dimension}&valueRenderOption=#{value_render_option}" <> 631 | "&dateTimeRenderOption=#{datetime_render_option}&#{str_ranges}" 632 | 633 | case spreadsheet_query(:get, query) do 634 | {:json, %{"valueRanges" => valueRanges}} -> 635 | {:reply, {:ok, parse_value_ranges(valueRanges, options)}, state} 636 | 637 | {:json, _} -> 638 | {:reply, {:ok, []}, state} 639 | 640 | {:error, exception} -> 641 | {:reply, {:error, exception}, state} 642 | end 643 | end 644 | 645 | def handle_call({:read_rows, row_index_start, row_index_end, options}, from, state) do 646 | column_from = Keyword.get(options, :column_from, @default_column_from) 647 | column_to = Keyword.get(options, :column_to, @default_column_to) 648 | 649 | options = 650 | if Keyword.get(options, :batch_range, true) do 651 | batched_range = range(row_index_start, row_index_end, column_from, column_to) 652 | 653 | options 654 | |> Keyword.put(:batched_ranges, [batched_range]) 655 | |> Keyword.put(:batched_rows, row_index_end - row_index_start + 1) 656 | else 657 | options 658 | end 659 | 660 | ranges = 661 | Enum.map(row_index_start..row_index_end, fn row_index -> 662 | range(row_index, row_index, column_from, column_to) 663 | end) 664 | 665 | handle_call({:read_rows, ranges, options}, from, state) 666 | end 667 | 668 | # Clear rows in spreadsheet by their index. 669 | def handle_call( 670 | {:clear_rows, ranges, _options}, 671 | _from, 672 | %{spreadsheet_id: spreadsheet_id} = state 673 | ) do 674 | str_ranges = 675 | ranges 676 | |> Enum.map(&{:ranges, "#{maybe_attach_list(state)}#{&1}"}) 677 | |> URI.encode_query() 678 | 679 | query = "#{spreadsheet_id}/values:batchClear?#{str_ranges}" 680 | 681 | case spreadsheet_query(:post, query) do 682 | {:json, %{"clearedRanges" => _}} -> 683 | {:reply, :ok, state} 684 | 685 | {:error, exception} -> 686 | {:reply, {:error, exception}, state} 687 | end 688 | end 689 | 690 | def handle_call({:clear_rows, row_index_start, row_index_end, options}, from, state) do 691 | column_from = Keyword.get(options, :column_from, @default_column_from) 692 | column_to = Keyword.get(options, :column_to, @default_column_to) 693 | 694 | ranges = 695 | Enum.map(row_index_start..row_index_end, fn row_index -> 696 | range(row_index, row_index, column_from, column_to) 697 | end) 698 | 699 | handle_call({:clear_rows, ranges, options}, from, state) 700 | end 701 | 702 | # Write values in batch based on a ranges schema. 703 | def handle_call( 704 | {:write_rows, ranges, data, options}, 705 | _from, 706 | %{spreadsheet_id: spreadsheet_id} = state 707 | ) do 708 | request_data = 709 | Enum.map(Enum.zip(ranges, data), fn {range, record} -> 710 | %{ 711 | range: "#{maybe_attach_list(state)}#{range}", 712 | values: [record], 713 | majorDimension: Keyword.get(options, :major_dimension, "ROWS") 714 | } 715 | end) 716 | 717 | request_body = %{ 718 | data: request_data, 719 | valueInputOption: Keyword.get(options, :value_input_option, "USER_ENTERED") 720 | # includeValuesInResponse: Keyword.get(options, :include_values_in_response, false), 721 | # responseValueRenderOption: Keyword.get(options, :response_value_render_option, "FORMATTED_VALUE"), 722 | # responseDateTimeRenderOption: Keyword.get(options, :response_date_time_render_option, "SERIAL_NUMBER") 723 | } 724 | 725 | query = "#{spreadsheet_id}/values:batchUpdate" 726 | 727 | case spreadsheet_query_post_batch(query, request_body, options) do 728 | {:json, %{"responses" => responses}} -> 729 | {:reply, {:ok, responses}, state} 730 | 731 | {:error, exception} -> 732 | {:reply, {:error, exception}, state} 733 | end 734 | end 735 | 736 | def handle_call( 737 | {:set_basic_filter, grid_range, params, options}, 738 | _from, 739 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 740 | ) do 741 | request = %{ 742 | setBasicFilter: %{ 743 | filter: Map.merge(%{range: grid_range(sheet_id, grid_range)}, filter_specs(params)) 744 | } 745 | } 746 | 747 | request_body = %{requests: [request]} 748 | batch_update_query(spreadsheet_id, request_body, options, state) 749 | end 750 | 751 | def handle_call( 752 | {:clear_basic_filter, options}, 753 | _from, 754 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 755 | ) do 756 | request_body = %{requests: [%{clearBasicFilter: %{sheetId: sheet_id}}]} 757 | batch_update_query(spreadsheet_id, request_body, options, state) 758 | end 759 | 760 | def handle_call( 761 | {:freeze_header, %{dim: dim, n_freeze: n_freeze}, options}, 762 | _from, 763 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 764 | ) do 765 | row_col = if dim == :col, do: "frozenColumnCount", else: "frozenRowCount" 766 | 767 | request = %{ 768 | updateSheetProperties: %{ 769 | properties: %{ 770 | sheetId: sheet_id, 771 | gridProperties: %{String.to_atom(row_col) => n_freeze} 772 | }, 773 | fields: "gridProperties." <> row_col 774 | } 775 | } 776 | 777 | request_body = %{requests: [request]} 778 | batch_update_query(spreadsheet_id, request_body, options, state) 779 | end 780 | 781 | def handle_call( 782 | {:update_col_width, %{col_idx: col_idx, col_width: col_width}, options}, 783 | _from, 784 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 785 | ) do 786 | request = %{ 787 | updateDimensionProperties: %{ 788 | range: %{ 789 | sheetId: sheet_id, 790 | dimension: "COLUMNS", 791 | startIndex: col_idx, 792 | endIndex: col_idx + 1 793 | }, 794 | properties: %{pixelSize: col_width}, 795 | fields: "pixelSize" 796 | } 797 | } 798 | 799 | request_body = %{requests: [request]} 800 | batch_update_query(spreadsheet_id, request_body, options, state) 801 | end 802 | 803 | def handle_call( 804 | {:add_number_format, grid_range, %{type: type, pattern: pattern}, options}, 805 | _from, 806 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 807 | ) do 808 | request = %{ 809 | repeatCell: %{ 810 | range: grid_range(sheet_id, grid_range), 811 | fields: "userEnteredFormat.numberFormat", 812 | cell: %{ 813 | userEnteredFormat: %{numberFormat: %{type: String.upcase(type), pattern: pattern}} 814 | } 815 | } 816 | } 817 | 818 | request_body = %{requests: [request]} 819 | batch_update_query(spreadsheet_id, request_body, options, state) 820 | end 821 | 822 | def handle_call( 823 | {:update_col_wrap, grid_range, %{wrap_strategy: wrap_strategy}, options}, 824 | _from, 825 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 826 | ) do 827 | request = %{ 828 | repeatCell: %{ 829 | range: grid_range(sheet_id, grid_range), 830 | fields: "userEnteredFormat.wrapStrategy", 831 | cell: %{userEnteredFormat: %{wrapStrategy: String.upcase(wrap_strategy)}} 832 | } 833 | } 834 | 835 | request_body = %{requests: [request]} 836 | batch_update_query(spreadsheet_id, request_body, options, state) 837 | end 838 | 839 | def handle_call( 840 | {:set_font, grid_range, %{font_family: font_family}, options}, 841 | _from, 842 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 843 | ) do 844 | request = %{ 845 | repeatCell: %{ 846 | range: grid_range(sheet_id, grid_range), 847 | fields: "userEnteredFormat.textFormat", 848 | cell: %{userEnteredFormat: %{textFormat: %{fontFamily: font_family}}} 849 | } 850 | } 851 | 852 | request_body = %{requests: [request]} 853 | batch_update_query(spreadsheet_id, request_body, options, state) 854 | end 855 | 856 | def handle_call( 857 | {:add_conditional_format, grid_range, %{formula: formula, color_map: color_map}, options}, 858 | _from, 859 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 860 | ) do 861 | request = %{ 862 | addConditionalFormatRule: %{ 863 | rule: %{ 864 | ranges: [grid_range(sheet_id, grid_range)], 865 | booleanRule: %{ 866 | condition: %{type: "CUSTOM_FORMULA", values: [%{userEnteredValue: formula}]}, 867 | format: %{backgroundColor: color_map} 868 | } 869 | } 870 | } 871 | } 872 | 873 | request_body = %{requests: [request]} 874 | batch_update_query(spreadsheet_id, request_body, options, state) 875 | end 876 | 877 | def handle_call( 878 | {:update_border, grid_range, params, options}, 879 | _from, 880 | %{spreadsheet_id: spreadsheet_id, sheet_id: sheet_id} = state 881 | ) do 882 | range = %{range: grid_range(sheet_id, grid_range)} 883 | 884 | border = 885 | Enum.reduce(params, %{}, fn {k, v}, acc -> 886 | case k in [:top, :bottom, :left, :right] do 887 | true -> 888 | border_v = %{ 889 | style: String.upcase(Map.get(v, :style) || "SOLID"), 890 | colorStyle: %{ 891 | rgbColor: %{ 892 | red: Map.get(v, :red) || 0, 893 | green: Map.get(v, :green) || 0, 894 | blue: Map.get(v, :blue) || 0, 895 | alpha: Map.get(v, :alpha) || 1 896 | } 897 | } 898 | } 899 | 900 | Map.put(acc, k, border_v) 901 | 902 | false -> 903 | raise GSS.InvalidInput, 904 | message: "Map key `#{k}` in params must be one of `[:top, :bottom, :left, :right]`" 905 | end 906 | end) 907 | 908 | request_body = %{requests: [%{updateBorders: Map.merge(range, border)}]} 909 | batch_update_query(spreadsheet_id, request_body, options, state) 910 | end 911 | 912 | def filter_specs(%{col_idx: col, condition_type: type, user_entered_value: value}) do 913 | %{ 914 | filterSpecs: [ 915 | %{ 916 | columnIndex: col, 917 | filterCriteria: %{condition: %{type: type, values: [%{userEnteredValue: value}]}} 918 | } 919 | ] 920 | } 921 | end 922 | 923 | def filter_specs(%{}), do: %{} 924 | 925 | def filter_specs(_) do 926 | raise GSS.InvalidInput, 927 | message: 928 | "Map `params` must either be empty or contain keys: `col_idx`, `condition_type`, `user_entered_value`" 929 | end 930 | 931 | def batch_update_query(spreadsheet_id, request_body, options, state) do 932 | query = "#{spreadsheet_id}:batchUpdate" 933 | 934 | case spreadsheet_query_post_batch(query, request_body, options) do 935 | {:json, %{"replies" => replies, "spreadsheetId" => _}} -> 936 | {:reply, {:ok, replies}, state} 937 | 938 | {:error, exception} -> 939 | {:reply, {:error, exception}, state} 940 | end 941 | end 942 | 943 | @spec spreadsheet_query(:get | :post, String.t()) :: spreadsheet_response 944 | defp spreadsheet_query(type, url_suffix) when is_atom(type) do 945 | headers = %{"Authorization" => "Bearer #{GSS.Registry.token()}"} 946 | params = get_request_params() 947 | response = Client.request(type, @api_url_spreadsheet <> url_suffix, "", headers, params) 948 | spreadsheet_query_response(response) 949 | end 950 | 951 | @spec spreadsheet_query(:post | :put, String.t(), spreadsheet_data, Keyword.t()) :: 952 | spreadsheet_response 953 | defp spreadsheet_query(type, url_suffix, data, options) when is_atom(type) do 954 | headers = %{"Authorization" => "Bearer #{GSS.Registry.token()}"} 955 | params = get_request_params() 956 | 957 | response = 958 | case type do 959 | :post -> 960 | body = spreadsheet_query_body(data, options) 961 | Client.request(:post, @api_url_spreadsheet <> url_suffix, body, headers, params) 962 | 963 | :put -> 964 | body = spreadsheet_query_body(data, options) 965 | Client.request(:put, @api_url_spreadsheet <> url_suffix, body, headers, params) 966 | end 967 | 968 | spreadsheet_query_response(response) 969 | end 970 | 971 | @spec spreadsheet_query_post_batch(String.t(), map(), Keyword.t()) :: spreadsheet_response 972 | defp spreadsheet_query_post_batch(url_suffix, request, _options) do 973 | headers = %{"Authorization" => "Bearer #{GSS.Registry.token()}"} 974 | params = get_request_params() 975 | body = Jason.encode!(request) 976 | response = Client.request(:post, @api_url_spreadsheet <> url_suffix, body, headers, params) 977 | spreadsheet_query_response(response) 978 | end 979 | 980 | @spec spreadsheet_query_response({:ok, Finch.Response.t()} | {:error, Exception.t()}) :: spreadsheet_response 981 | defp spreadsheet_query_response(response) do 982 | with {:ok, %{status: 200, body: body}} <- response, 983 | {:ok, json} <- Jason.decode(body) do 984 | {:json, json} 985 | else 986 | {:ok, %{status: status, body: body}} when status != 200 -> 987 | Logger.error("[#{__MODULE__}] Google API returned status code: #{status}. Body: #{body}") 988 | {:error, %GSS.GoogleApiError{message: "invalid google API status code #{status}"}} 989 | 990 | {:error, reason} -> 991 | Logger.error("[#{__MODULE__}] spreadsheet query: #{inspect(reason)}") 992 | {:error, %GSS.GoogleApiError{message: "invalid google API response #{inspect(reason)}"}} 993 | end 994 | end 995 | 996 | @spec spreadsheet_query_body(spreadsheet_data, Keyword.t()) :: String.t() | no_return() 997 | defp spreadsheet_query_body(data, options) do 998 | range = Keyword.fetch!(options, :range) 999 | major_dimension = Keyword.get(options, :major_dimension, "ROWS") 1000 | wrap_data = Keyword.get(options, :wrap_data, true) 1001 | 1002 | Jason.encode!(%{ 1003 | range: range, 1004 | majorDimension: major_dimension, 1005 | values: if(wrap_data, do: [data], else: data) 1006 | }) 1007 | end 1008 | 1009 | @spec range(integer(), integer(), integer(), integer()) :: String.t() 1010 | def range(row_from, row_to, column_from, column_to) 1011 | when row_from <= row_to and column_from <= column_to and row_to - row_from < @max_rows do 1012 | column_from_letters = col_number_to_letters(column_from) 1013 | column_to_letters = col_number_to_letters(column_to) 1014 | "#{column_from_letters}#{row_from}:#{column_to_letters}#{row_to}" 1015 | end 1016 | 1017 | def range(_, _, _, _) do 1018 | raise GSS.InvalidRange, 1019 | message: "Max rows #{@max_rows}, `to` value should be greater than `from`" 1020 | end 1021 | 1022 | @spec range(integer(), integer(), integer(), integer(), state) :: String.t() 1023 | def range(row_from, row_to, column_from, column_to, state) do 1024 | maybe_attach_list(state) <> range(row_from, row_to, column_from, column_to) 1025 | end 1026 | 1027 | @doc """ 1028 | Combine the sheet_id into the grid_range, and drop any values from the range that are nil. 1029 | """ 1030 | @spec grid_range(integer(), grid_range()) :: map() 1031 | def grid_range(sheet_id, %{row_from: rf, row_to: rt, col_from: cf, col_to: ct}) do 1032 | %{ 1033 | sheetId: sheet_id, 1034 | startRowIndex: rf, 1035 | endRowIndex: rt, 1036 | startColumnIndex: cf, 1037 | endColumnIndex: ct 1038 | } 1039 | |> Map.filter(fn {_k, v} -> v != nil end) 1040 | end 1041 | 1042 | @spec pad(integer()) :: spreadsheet_data 1043 | defp pad(amount) do 1044 | for _i <- 1..amount, do: "" 1045 | end 1046 | 1047 | @spec col_number_to_letters(integer()) :: String.t() 1048 | def col_number_to_letters(col_number) do 1049 | indices = index_to_index_list(col_number - 1) 1050 | charlist = for i <- indices, do: i + ?A 1051 | to_string(charlist) 1052 | end 1053 | 1054 | defp index_to_index_list(index, list \\ []) 1055 | 1056 | defp index_to_index_list(index, list) when index >= 26 do 1057 | index_to_index_list(div(index, 26) - 1, [rem(index, 26) | list]) 1058 | end 1059 | 1060 | defp index_to_index_list(index, list) when index >= 0 and index < 26 do 1061 | [index | list] 1062 | end 1063 | 1064 | @spec maybe_attach_list(state) :: String.t() 1065 | defp maybe_attach_list(%{list_name: nil}), do: "" 1066 | 1067 | defp maybe_attach_list(%{list_name: list_name}) when is_bitstring(list_name), 1068 | do: "#{list_name}!" 1069 | 1070 | @spec parse_value_ranges([map()], Keyword.t()) :: [[String.t()] | nil] 1071 | defp parse_value_ranges(value_ranges, options) do 1072 | column_to = Keyword.get(options, :column_to) 1073 | parse_value_ranges(value_ranges, options, column_to) 1074 | end 1075 | 1076 | @spec parse_value_ranges([map()], Keyword.t(), integer() | nil) :: [[String.t()] | nil] 1077 | defp parse_value_ranges(value_ranges, options, column_to) 1078 | when is_integer(column_to) or is_nil(column_to) do 1079 | total_rows = Keyword.get(options, :batched_rows, 1) 1080 | pad_empty = Keyword.get(options, :pad_empty, false) 1081 | response = Enum.flat_map(value_ranges, &parse_value(&1, column_to, pad_empty)) 1082 | 1083 | if length(response) < total_rows do 1084 | padding = List.duplicate(empty_row(column_to, pad_empty), total_rows - length(response)) 1085 | response ++ padding 1086 | else 1087 | response 1088 | end 1089 | end 1090 | 1091 | @spec parse_value(map(), integer() | nil, boolean()) :: [[String.t()] | nil] 1092 | defp parse_value(%{"values" => values}, column_to, _) 1093 | when is_list(values) and is_integer(column_to) do 1094 | Enum.map(values, &value_range_block_wrapper(&1, column_to)) 1095 | end 1096 | 1097 | defp parse_value(%{"values" => values}, _, false) when is_list(values), do: values 1098 | defp parse_value(_, column_to, pad_empty), do: [empty_row(column_to, pad_empty)] 1099 | 1100 | @spec empty_row(integer() | nil, boolean()) :: [String.t()] | nil 1101 | defp empty_row(nil, true), do: [] 1102 | defp empty_row(column_to, true), do: pad(column_to) 1103 | defp empty_row(_, false), do: nil 1104 | 1105 | @spec value_range_block_wrapper([String.t()], integer()) :: [String.t()] 1106 | defp value_range_block_wrapper(values, column_to) when length(values) >= column_to, do: values 1107 | 1108 | defp value_range_block_wrapper(values, column_to) do 1109 | pad_amount = column_to - length(values) 1110 | values ++ pad(pad_amount) 1111 | end 1112 | 1113 | @spec get_request_params() :: Keyword.t() 1114 | defp get_request_params do 1115 | Client.config(:request_opts, []) 1116 | end 1117 | 1118 | defp gen_server_call(pid, tuple, options) do 1119 | case Keyword.get(options, :timeout) do 1120 | nil -> 1121 | GenServer.call(pid, tuple) 1122 | 1123 | timeout -> 1124 | GenServer.call(pid, tuple, timeout) 1125 | end 1126 | end 1127 | end 1128 | --------------------------------------------------------------------------------