diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26c04be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,5 @@ +phoenix: + build: spec_example + ports: + - "4000:4000" + command: mix phx.server diff --git a/lib/phoenix/socket.rb b/lib/phoenix/socket.rb index 4e5464f..0bea529 100644 --- a/lib/phoenix/socket.rb +++ b/lib/phoenix/socket.rb @@ -7,6 +7,7 @@ module Phoenix class Socket include MonitorMixin attr_reader :path, :socket, :inbox, :topic, :join_options + attr_accessor :verbose def initialize(topic, join_options: {}, path: 'ws://localhost:4000/socket/websocket') @path = path @@ -17,17 +18,33 @@ def initialize(topic, join_options: {}, path: 'ws://localhost:4000/socket/websoc @inbox_cond = new_cond @thread_ready = new_cond @topic_cond = new_cond - @join_ref = SecureRandom.uuid + reset_state_conditions end # Simulate a synchronous call over the websocket - def request_reply(event:, payload: {}) + # TODO: use a queue/inbox/outbox here instead + def request_reply(event:, payload: {}, timeout: 5) # timeout in seconds ref = SecureRandom.uuid - ensure_thread synchronize do + ensure_connection @topic_cond.wait_until { @topic_joined } EM.next_tick { socket.send({ topic: topic, event: event, payload: payload, ref: ref }.to_json) } - inbox_cond.wait_until { inbox.key?(ref) || @dead } + log [event, ref] + + # Ruby's condition variables only support timeout on the basic 'wait' method; + # This should behave roughly as if wait_until also support a timeout: + # `inbox_cond.wait_until(timeout) { inbox.key?(ref) || @dead } + # + # Note that this serves only to unblock the main thread, and should not halt execution of the + # socket connection. Therefore, there is a possibility that the inbox may pile up with + # unread messages if a lot of timeouts are encountered. A self-sweeping inbox will + # be implemented to prevent this. + ts = Time.now + loop do + inbox_cond.wait(timeout) # waits until time expires or signaled + break if inbox.key?(ref) || @dead + raise 'timeout' if timeout && Time.now > (ts + timeout) + end inbox.delete(ref) { raise "reply #{ref} not found" } end end @@ -36,12 +53,17 @@ def request_reply(event:, payload: {}) attr_reader :inbox_cond, :thread_ready - def ensure_thread + def log(msg) + return unless @verbose + puts "[#{Thread.current[:id]}] #{msg} (#@topic_joined)" + end + + def ensure_connection connection_alive? or synchronize do spawn_thread thread_ready.wait(3) if @dead - @spawning = false + @spawned = false raise 'dead connection timeout' end end @@ -51,45 +73,73 @@ def connection_alive? @ws_thread&.alive? && !@dead end + def handle_close(event) + synchronize do + reset_state_conditions + inbox_cond.signal + thread_ready.signal + end + end + + def reset_state_conditions + @dead = true # no EM thread active, or the connection has been closed + @socket = nil # the Faye::Websocket::Client instance + @spawned = false # The thread running (or about to run) EventMachine has been launched + @join_ref = SecureRandom.uuid # unique id that Phoenix uses to identify the socket <-> channel connection + @topic_joined = false # The initial join request has been acked by the remote server + end + + def handle_message(event) + data = JSON.parse(event.data) + log event.data + synchronize do + if data['event'] == 'phx_close' + log('handling close from message') + handle_close(event) + elsif data['ref'] == @join_ref && data['event'] == 'phx_error' + # NOTE: For some reason, on errors phx will send the join ref instead of the message ref + inbox_cond.broadcast + elsif data['ref'] == @join_ref + log ['join_ref', @join_ref] + @topic_joined = true + @topic_cond.broadcast + else + inbox[data['ref']] = data + inbox_cond.broadcast + end + end + end + + def handle_open(event) + log 'open' + socket.send({ topic: topic, event: "phx_join", payload: join_options, ref: @join_ref, join_ref: @join_ref }.to_json) + synchronize do + @dead = false + thread_ready.broadcast + end + end + def spawn_thread - return if @spawning - puts 'spawn_thread' - @spawning = true + return if @spawned || connection_alive? + log 'spawning...' + @spawned = true @ws_thread = Thread.new do + Thread.current[:id] = "WSTHREAD_#{SecureRandom.hex(3)}" EM.run do synchronize do + log 'em.run.sync' @socket = Faye::WebSocket::Client.new(path) socket.on :open do |event| - p [:open] - socket.send({ topic: topic, event: "phx_join", payload: join_options, ref: @join_ref }.to_json) - synchronize do - @dead = false - @spawning = false - thread_ready.broadcast - end + handle_open(event) end socket.on :message do |event| - data = JSON.parse(event.data) - synchronize do - if data['ref'] == @join_ref - @topic_joined = true - @topic_cond.broadcast - else - inbox[data['ref']] = data - inbox_cond.broadcast - end - end + handle_message(event) end socket.on :close do |event| - p [:close, event.code, event.reason] - synchronize do - @socket = nil - @dead = true - inbox_cond.signal - thread_ready.signal - end + log [:close, event.code, event.reason] + handle_close(event) EM::stop end end diff --git a/lib/phoenix/socket/version.rb b/lib/phoenix/socket/version.rb index 706bbd9..208f92f 100644 --- a/lib/phoenix/socket/version.rb +++ b/lib/phoenix/socket/version.rb @@ -1,7 +1,7 @@ module Rb module Phoenix module Socket - VERSION = "0.1.1" + VERSION = "0.2.0" end end end diff --git a/spec/phoenix/socket_spec.rb b/spec/phoenix/socket_spec.rb new file mode 100644 index 0000000..2e02691 --- /dev/null +++ b/spec/phoenix/socket_spec.rb @@ -0,0 +1,69 @@ +require 'pry-byebug' +require "spec_helper" + +RSpec.describe Phoenix::Socket do + it "has a version number" do + expect(Rb::Phoenix::Socket::VERSION).not_to be nil + end + + let(:socket_handler) do + Phoenix::Socket.new("rspec:default", path: "ws://#{`docker-machine ip`.strip}:4000/socket/websocket") + end + + it 'echoes back the requested payload' do + response = socket_handler.request_reply(event: :echo, payload: { foo: :bar }) + expect(response['event']).to eq('phx_reply') + expect(response['topic']).to eq('rspec:default') + expect(response['payload']).to eq({ 'status' => 'ok', 'response' => { 'foo' => 'bar' }}) + end + + it 'handles concurrent threads' do + # NOTE: This is a proof of concept, and is WAY more than anyone would ever want/need + # to spawn in a runtime process. I.e. don't do this. If one at a time isn't enough, + # do it in Elixir. Although you should probably also ask yourself why you need 500 processes + # to share a single websocket. + responses = (0..500).map do |n| + Thread.new do + Thread.current[:id] = n + socket_handler.request_reply(event: :echo, payload: { n: n }, timeout: nil) + end + end.map(&:value) + + responses.each_with_index do |response, index| + expect(response['payload']).to eq({ 'status' => 'ok', 'response' => { 'n' => index }}) + end + end + + describe 're-spawn' do + 20.times do |n| + # NOTE: Running this multiple times because there was some unexpected thread scheduling + # behavior that came up during development. Generally came up at least 1 / 5 times; + # running 20 for safety. + it "handles termination but respawns the connection handler" do + expect { socket_handler.request_reply(event: :unsupported) }.to raise_error(RuntimeError, /reply .* not found/) + expect(socket_handler.request_reply(event: :echo)['payload']['status']).to eq('ok') + + # Ensure dead handler threads have been cleaned up; we should have at most + # the live main thread and a live respawned handler + expect(Thread.list.count).to be < 3 + end + end + end + + describe 'timeout handling' do + specify 'small sleep' do + response = socket_handler.request_reply(event: :sleep, payload: { ms: 50 }) + expect(response.dig('payload', 'status')).to eq('ok') + end + + specify 'long sleep' do + response = socket_handler.request_reply(event: :sleep, payload: { ms: 1000 }) + expect(response.dig('payload', 'status')).to eq('ok') + end + + specify 'sleep exceeding timeout' do + expect { socket_handler.request_reply(timeout: 0.5, event: :sleep, payload: { ms: 1000 }) }.to raise_error(RuntimeError, /timeout/) + expect { socket_handler.request_reply(timeout: 0.5, event: :sleep, payload: { ms: 10 }) }.not_to raise_error(RuntimeError, /timeout/) + end + end +end diff --git a/spec/rb/phoenix/socket_spec.rb b/spec/rb/phoenix/socket_spec.rb deleted file mode 100644 index b6a5fce..0000000 --- a/spec/rb/phoenix/socket_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "spec_helper" - -RSpec.describe Rb::Phoenix::Socket do - it "has a version number" do - expect(Rb::Phoenix::Socket::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ca1f6db..db913fd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,5 @@ require "bundler/setup" -require "rb/phoenix/socket" +require "phoenix/socket" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec_example/.dockerignore b/spec_example/.dockerignore new file mode 100644 index 0000000..a744689 --- /dev/null +++ b/spec_example/.dockerignore @@ -0,0 +1,5 @@ +# App artifacts +/_build +/db +/deps +/*.ez diff --git a/spec_example/.gitignore b/spec_example/.gitignore new file mode 100644 index 0000000..d26faf8 --- /dev/null +++ b/spec_example/.gitignore @@ -0,0 +1,16 @@ +# App artifacts +/_build +/db +/deps +/*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Files matching config/*.secret.exs pattern contain sensitive +# data and you should not commit them into version control. +# +# Alternatively, you may comment the line below and commit the +# secrets files as long as you replace their contents by environment +# variables. +/config/*.secret.exs diff --git a/spec_example/Dockerfile b/spec_example/Dockerfile new file mode 100644 index 0000000..f02038b --- /dev/null +++ b/spec_example/Dockerfile @@ -0,0 +1,15 @@ +FROM elixir:1.5.1-alpine + +ENV LANG=C.UTF-8 +WORKDIR /usr/src/app + +ENV MIX_ENV=dev +RUN mix local.hex --force && mix local.rebar --force + +COPY mix* ./ +RUN mix deps.get && mix compile + +COPY . ./ +RUN mix deps.get && mix compile + +CMD ["mix", "phx.server"] diff --git a/spec_example/README.md b/spec_example/README.md new file mode 100644 index 0000000..8bc3b37 --- /dev/null +++ b/spec_example/README.md @@ -0,0 +1,18 @@ +# SpecExample + +To start your Phoenix server: + + * Install dependencies with `mix deps.get` + * Start Phoenix endpoint with `mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](http://www.phoenixframework.org/docs/deployment). + +## Learn more + + * Official website: http://www.phoenixframework.org/ + * Guides: http://phoenixframework.org/docs/overview + * Docs: https://hexdocs.pm/phoenix + * Mailing list: http://groups.google.com/group/phoenix-talk + * Source: https://github.com/phoenixframework/phoenix diff --git a/spec_example/config/config.exs b/spec_example/config/config.exs new file mode 100644 index 0000000..1a1d69d --- /dev/null +++ b/spec_example/config/config.exs @@ -0,0 +1,23 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +use Mix.Config + +# Configures the endpoint +config :spec_example, SpecExampleWeb.Endpoint, + url: [host: "localhost"], + secret_key_base: "7Y7mZ/GEwoVarXDx6yNFzs6nXyuh3r93hggda16yzFtarWlLCillTwenNrNjjyra", + render_errors: [view: SpecExampleWeb.ErrorView, accepts: ~w(json)], + pubsub: [name: SpecExample.PubSub, + adapter: Phoenix.PubSub.PG2] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env}.exs" diff --git a/spec_example/config/dev.exs b/spec_example/config/dev.exs new file mode 100644 index 0000000..ad2f1c5 --- /dev/null +++ b/spec_example/config/dev.exs @@ -0,0 +1,37 @@ +use Mix.Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with brunch.io to recompile .js and .css sources. +config :spec_example, SpecExampleWeb.Endpoint, + http: [port: 4000], + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# command from your terminal: +# +# openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem +# +# The `http:` config above can be replaced with: +# +# https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 diff --git a/spec_example/config/prod.exs b/spec_example/config/prod.exs new file mode 100644 index 0000000..8d9aca0 --- /dev/null +++ b/spec_example/config/prod.exs @@ -0,0 +1,64 @@ +use Mix.Config + +# For production, we often load configuration from external +# sources, such as your system environment. For this reason, +# you won't find the :http configuration below, but set inside +# SpecExampleWeb.Endpoint.init/2 when load_from_system_env is +# true. Any dynamic configuration should be done there. +# +# Don't forget to configure the url host to something meaningful, +# Phoenix uses this information when generating URLs. +# +# Finally, we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the mix phx.digest task +# which you typically run after static files are built. +config :spec_example, SpecExampleWeb.Endpoint, + load_from_system_env: true, + url: [host: "example.com", port: 80], + cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# ## SSL Support +# +# To get SSL working, you will need to add the `https` key +# to the previous section and set your `:url` port to 443: +# +# config :spec_example, SpecExampleWeb.Endpoint, +# ... +# url: [host: "example.com", port: 443], +# https: [:inet6, +# port: 443, +# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), +# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] +# +# Where those two env variables return an absolute path to +# the key and cert in disk or a relative path inside priv, +# for example "priv/ssl/server.key". +# +# We also recommend setting `force_ssl`, ensuring no data is +# ever sent via http, always redirecting to https: +# +# config :spec_example, SpecExampleWeb.Endpoint, +# force_ssl: [hsts: true] +# +# Check `Plug.SSL` for all available options in `force_ssl`. + +# ## Using releases +# +# If you are doing OTP releases, you need to instruct Phoenix +# to start the server for all endpoints: +# +# config :phoenix, :serve_endpoints, true +# +# Alternatively, you can configure exactly which server to +# start per endpoint: +# +# config :spec_example, SpecExampleWeb.Endpoint, server: true +# + +# Finally import the config/prod.secret.exs +# which should be versioned separately. +import_config "prod.secret.exs" diff --git a/spec_example/config/test.exs b/spec_example/config/test.exs new file mode 100644 index 0000000..c4f0488 --- /dev/null +++ b/spec_example/config/test.exs @@ -0,0 +1,10 @@ +use Mix.Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :spec_example, SpecExampleWeb.Endpoint, + http: [port: 4001], + server: false + +# Print only warnings and errors during test +config :logger, level: :warn diff --git a/spec_example/lib/spec_example.ex b/spec_example/lib/spec_example.ex new file mode 100644 index 0000000..036c0d6 --- /dev/null +++ b/spec_example/lib/spec_example.ex @@ -0,0 +1,9 @@ +defmodule SpecExample do + @moduledoc """ + SpecExample keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/spec_example/lib/spec_example/application.ex b/spec_example/lib/spec_example/application.ex new file mode 100644 index 0000000..486b06c --- /dev/null +++ b/spec_example/lib/spec_example/application.ex @@ -0,0 +1,29 @@ +defmodule SpecExample.Application do + use Application + + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec + + # Define workers and child supervisors to be supervised + children = [ + # Start the endpoint when the application starts + supervisor(SpecExampleWeb.Endpoint, []), + # Start your own worker by calling: SpecExample.Worker.start_link(arg1, arg2, arg3) + # worker(SpecExample.Worker, [arg1, arg2, arg3]), + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: SpecExample.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + SpecExampleWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/spec_example/lib/spec_example_web.ex b/spec_example/lib/spec_example_web.ex new file mode 100644 index 0000000..374d3e0 --- /dev/null +++ b/spec_example/lib/spec_example_web.ex @@ -0,0 +1,64 @@ +defmodule SpecExampleWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use SpecExampleWeb, :controller + use SpecExampleWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: SpecExampleWeb + import Plug.Conn + import SpecExampleWeb.Router.Helpers + import SpecExampleWeb.Gettext + end + end + + def view do + quote do + use Phoenix.View, root: "lib/spec_example_web/templates", + namespace: SpecExampleWeb + + # Import convenience functions from controllers + import Phoenix.Controller, only: [get_flash: 2, view_module: 1] + + import SpecExampleWeb.Router.Helpers + import SpecExampleWeb.ErrorHelpers + import SpecExampleWeb.Gettext + end + end + + def router do + quote do + use Phoenix.Router + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + import SpecExampleWeb.Gettext + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/spec_example/lib/spec_example_web/channels/rspec_channel.ex b/spec_example/lib/spec_example_web/channels/rspec_channel.ex new file mode 100644 index 0000000..22a806c --- /dev/null +++ b/spec_example/lib/spec_example_web/channels/rspec_channel.ex @@ -0,0 +1,21 @@ +defmodule SpecExampleWeb.RSpecChannel do + use Phoenix.Channel + + def join("rspec:default", _message, socket) do + {:ok, socket} + end + + def handle_in("echo", payload, socket) do + {:reply, {:ok, payload}, socket} + end + + # Simulate a long-running request + def handle_in("sleep", %{ "ms" => ms } = payload, socket) do + Process.sleep(ms) + {:reply, {:ok, payload}, socket} + end + + def handle_in(_, _, socket) do + {:stop, :shutdown, socket} + end +end diff --git a/spec_example/lib/spec_example_web/channels/user_socket.ex b/spec_example/lib/spec_example_web/channels/user_socket.ex new file mode 100644 index 0000000..76ad9ca --- /dev/null +++ b/spec_example/lib/spec_example_web/channels/user_socket.ex @@ -0,0 +1,37 @@ +defmodule SpecExampleWeb.UserSocket do + use Phoenix.Socket + + ## Channels + channel "rspec:default", SpecExampleWeb.RSpecChannel + + ## Transports + transport :websocket, Phoenix.Transports.WebSocket + # transport :longpoll, Phoenix.Transports.LongPoll + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error`. + # + # See `Phoenix.Token` documentation for examples in + # performing token verification on connect. + def connect(_params, socket) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # SpecExampleWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) + # + # Returning `nil` makes this socket anonymous. + def id(_socket), do: nil +end diff --git a/spec_example/lib/spec_example_web/endpoint.ex b/spec_example/lib/spec_example_web/endpoint.ex new file mode 100644 index 0000000..c492835 --- /dev/null +++ b/spec_example/lib/spec_example_web/endpoint.ex @@ -0,0 +1,55 @@ +defmodule SpecExampleWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :spec_example + + socket "/socket", SpecExampleWeb.UserSocket + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phoenix.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", from: :spec_example, gzip: false, + only: ~w(css fonts images js favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Logger + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Poison + + plug Plug.MethodOverride + plug Plug.Head + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + plug Plug.Session, + store: :cookie, + key: "_spec_example_key", + signing_salt: "GJPmD3Ca" + + plug SpecExampleWeb.Router + + @doc """ + Callback invoked for dynamically configuring the endpoint. + + It receives the endpoint configuration and checks if + configuration should be loaded from the system environment. + """ + def init(_key, config) do + if config[:load_from_system_env] do + port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" + {:ok, Keyword.put(config, :http, [:inet6, port: port])} + else + {:ok, config} + end + end +end diff --git a/spec_example/lib/spec_example_web/gettext.ex b/spec_example/lib/spec_example_web/gettext.ex new file mode 100644 index 0000000..6ae79ea --- /dev/null +++ b/spec_example/lib/spec_example_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule SpecExampleWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import SpecExampleWeb.Gettext + + # Simple translation + gettext "Here is the string to translate" + + # Plural translation + ngettext "Here is the string to translate", + "Here are the strings to translate", + 3 + + # Domain-based translation + dgettext "errors", "Here is the error message to translate" + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :spec_example +end diff --git a/spec_example/lib/spec_example_web/router.ex b/spec_example/lib/spec_example_web/router.ex new file mode 100644 index 0000000..377f678 --- /dev/null +++ b/spec_example/lib/spec_example_web/router.ex @@ -0,0 +1,11 @@ +defmodule SpecExampleWeb.Router do + use SpecExampleWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", SpecExampleWeb do + pipe_through :api + end +end diff --git a/spec_example/lib/spec_example_web/views/error_helpers.ex b/spec_example/lib/spec_example_web/views/error_helpers.ex new file mode 100644 index 0000000..57951a4 --- /dev/null +++ b/spec_example/lib/spec_example_web/views/error_helpers.ex @@ -0,0 +1,29 @@ +defmodule SpecExampleWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # Because error messages were defined within Ecto, we must + # call the Gettext module passing our Gettext backend. We + # also use the "errors" domain as translations are placed + # in the errors.po file. + # Ecto will pass the :count keyword if the error message is + # meant to be pluralized. + # On your own code and templates, depending on whether you + # need the message to be pluralized or not, this could be + # written simply as: + # + # dngettext "errors", "1 file", "%{count} files", count + # dgettext "errors", "is invalid" + # + if count = opts[:count] do + Gettext.dngettext(SpecExampleWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(SpecExampleWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/spec_example/lib/spec_example_web/views/error_view.ex b/spec_example/lib/spec_example_web/views/error_view.ex new file mode 100644 index 0000000..95d5cfc --- /dev/null +++ b/spec_example/lib/spec_example_web/views/error_view.ex @@ -0,0 +1,17 @@ +defmodule SpecExampleWeb.ErrorView do + use SpecExampleWeb, :view + + def render("404.json", _assigns) do + %{errors: %{detail: "Page not found"}} + end + + def render("500.json", _assigns) do + %{errors: %{detail: "Internal server error"}} + end + + # In case no render clause matches or no + # template is found, let's render it as 500 + def template_not_found(_template, assigns) do + render "500.json", assigns + end +end diff --git a/spec_example/mix.exs b/spec_example/mix.exs new file mode 100644 index 0000000..1fb6c65 --- /dev/null +++ b/spec_example/mix.exs @@ -0,0 +1,41 @@ +defmodule SpecExample.Mixfile do + use Mix.Project + + def project do + [ + app: :spec_example, + version: "0.0.1", + elixir: "~> 1.4", + elixirc_paths: elixirc_paths(Mix.env), + compilers: [:phoenix, :gettext] ++ Mix.compilers, + start_permanent: Mix.env == :prod, + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {SpecExample.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.3.0"}, + {:phoenix_pubsub, "~> 1.0"}, + {:gettext, "~> 0.11"}, + {:cowboy, "~> 1.0"} + ] + end +end diff --git a/spec_example/mix.lock b/spec_example/mix.lock new file mode 100644 index 0000000..6f20624 --- /dev/null +++ b/spec_example/mix.lock @@ -0,0 +1,9 @@ +%{"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.13.1", "5e0daf4e7636d771c4c71ad5f3f53ba09a9ae5c250e1ab9c42ba9edccc476263", [], [], "hexpm"}, + "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [], [], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [], [], "hexpm"}, + "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [], [], "hexpm"}} diff --git a/spec_example/priv/gettext/en/LC_MESSAGES/errors.po b/spec_example/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/spec_example/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/spec_example/priv/gettext/errors.pot b/spec_example/priv/gettext/errors.pot new file mode 100644 index 0000000..6988141 --- /dev/null +++ b/spec_example/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. + diff --git a/spec_example/test/spec_example_web/views/error_view_test.exs b/spec_example/test/spec_example_web/views/error_view_test.exs new file mode 100644 index 0000000..02de59e --- /dev/null +++ b/spec_example/test/spec_example_web/views/error_view_test.exs @@ -0,0 +1,21 @@ +defmodule SpecExampleWeb.ErrorViewTest do + use SpecExampleWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.json" do + assert render(SpecExampleWeb.ErrorView, "404.json", []) == + %{errors: %{detail: "Page not found"}} + end + + test "render 500.json" do + assert render(SpecExampleWeb.ErrorView, "500.json", []) == + %{errors: %{detail: "Internal server error"}} + end + + test "render any other" do + assert render(SpecExampleWeb.ErrorView, "505.json", []) == + %{errors: %{detail: "Internal server error"}} + end +end diff --git a/spec_example/test/support/channel_case.ex b/spec_example/test/support/channel_case.ex new file mode 100644 index 0000000..9b7efa7 --- /dev/null +++ b/spec_example/test/support/channel_case.ex @@ -0,0 +1,33 @@ +defmodule SpecExampleWeb.ChannelCase do + @moduledoc """ + This module defines the test case to be used by + channel tests. + + Such tests rely on `Phoenix.ChannelTest` and also + import other functionality to make it easier + to build common datastructures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with channels + use Phoenix.ChannelTest + + # The default endpoint for testing + @endpoint SpecExampleWeb.Endpoint + end + end + + + setup _tags do + :ok + end + +end diff --git a/spec_example/test/support/conn_case.ex b/spec_example/test/support/conn_case.ex new file mode 100644 index 0000000..2f33f81 --- /dev/null +++ b/spec_example/test/support/conn_case.ex @@ -0,0 +1,34 @@ +defmodule SpecExampleWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common datastructures and query the data layer. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + use Phoenix.ConnTest + import SpecExampleWeb.Router.Helpers + + # The default endpoint for testing + @endpoint SpecExampleWeb.Endpoint + end + end + + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end + +end diff --git a/spec_example/test/test_helper.exs b/spec_example/test/test_helper.exs new file mode 100644 index 0000000..0a76a58 --- /dev/null +++ b/spec_example/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +