Compare commits

...
This repository has been archived on 2024-06-24. You can view files and clone it, but cannot push or open issues or pull requests.

24 commits

Author SHA1 Message Date
James Harton
2945694453 Use an access token because it avoids the rate limit. 2018-04-10 18:18:53 +12:00
James Harton
bc966b7b9c add go.sh 2018-04-10 12:47:56 +12:00
James Harton
0ad4642970 Update readme. 2018-04-09 13:17:05 +12:00
James Harton
794f8e75f5 UPDATE README 2018-04-08 17:22:29 +12:00
James Harton
3b8eede7d1 Update readme. 2018-04-08 16:24:13 +12:00
James Harton
c20d4b453e Add postgres database triggers and use them to notify subscribers. 2018-04-08 16:18:48 +12:00
James Harton
39373e2147 Update the README. 2018-04-08 15:27:20 +12:00
James Harton
f744866721 Client-side subscriptions via Apollo work. 2018-04-08 15:26:22 +12:00
James Harton
8a4ed2c727 Add absinthe-socket-link and send all GraphQL via a Phoenix channel. 2018-04-08 15:00:11 +12:00
James Harton
be3f4203d2 Update README. 2018-04-08 14:34:46 +12:00
James Harton
30e9b1c82b Add react-apollo and make UI work via GraphQL. 2018-04-08 14:30:36 +12:00
James Harton
ee94428bec Set up React. 2018-04-08 13:20:13 +12:00
James Harton
99f0f214e1 Remove server-side face rendering and importing. 2018-04-08 13:00:09 +12:00
James Harton
f3bee7434d Update README. 2018-04-08 12:43:50 +12:00
James Harton
69f2bdbb75 Get Absinthe working with list and creating people. 2018-04-08 12:42:50 +12:00
James Harton
707452b649 Fix timestamps to always create as :utc_datetime. 2018-04-08 12:25:31 +12:00
James Harton
d7c98d6a47 Ready for step 3. 2018-04-08 11:24:08 +12:00
James Harton
9075cfedf5 Allow users to add a github username via the UI. 2018-04-08 11:17:58 +12:00
James Harton
4a3670f676 Working face gallery app with server rendered page. 2018-04-08 10:47:22 +12:00
James Harton
7ec94ec686 Add face importing from github. 2018-04-08 10:06:11 +12:00
James Harton
d38a867de1 Add very simple context for face gallery. 2018-04-08 09:29:25 +12:00
James Harton
be7f3a2981 Empty Phoenix application. 2018-04-08 09:20:53 +12:00
James Harton
9edfb03977 Initial readme. 2018-04-08 09:17:28 +12:00
James Harton
b4a66b9c79 Update readme for step 1. 2018-04-08 09:08:06 +12:00
67 changed files with 5863 additions and 12 deletions

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# App artifacts
/_build
/db
/deps
/*.ez
.envrc
# Generated on crash by the VM
erl_crash.dump
# Generated on crash by NPM
npm-debug.log
# Static artifacts
/assets/node_modules
# Since we are building assets from assets/,
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
# 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

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"files.associations": {
"*.js": "javascriptreact"
},
"eslint.packageManager": "yarn"
}

View file

@ -1,15 +1,7 @@
# Wellington Elixir Meetup GraphQL Lightning Talk
## Because I counldn't think of a snappier title
# All done.
This is a very quick overview of what GraphQL is and what it's useful for. A lot of this info can be gleaned directly from the [Absinthe](https://absinthe-graphql.org/) documentation.
I hope you've enjoyed our little trip around GraphQL.
Things we're going to cover in this presentation:
All code for this talk is available at [gitlab.com/jimsy/graphql-lightning-talk](https://gitlab.com/jimsy/graphql-lightning-talk).
* What the heck is GraphQL anyway?
* Setting it up with Phoenix
* Handling Queries
* Handling Mutations
* Handling Subscriptions
* Extra for experts
To get started checkout the `step-1` branch.
I'm `@jimsy` on the [AU/NZ Elixir Slack](http://aunz-elixir.herokuapp.com/) - feel free to ask me questions there.

36
assets/.eslintrc.js Normal file
View file

@ -0,0 +1,36 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"indent": [
"error",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
};

64
assets/brunch-config.js Normal file
View file

@ -0,0 +1,64 @@
exports.config = {
// See http://brunch.io/#documentation for docs.
files: {
javascripts: {
joinTo: "js/app.js"
// To use a separate vendor.js bundle, specify two files path
// http://brunch.io/docs/config#-files-
// joinTo: {
// "js/app.js": /^js/,
// "js/vendor.js": /^(?!js)/
// }
//
// To change the order of concatenation of files, explicitly mention here
// order: {
// before: [
// "vendor/js/jquery-2.1.1.js",
// "vendor/js/bootstrap.min.js"
// ]
// }
},
stylesheets: {
joinTo: "css/app.css"
},
templates: {
joinTo: "js/app.js"
}
},
conventions: {
// This option sets where we should place non-css and non-js assets in.
// By default, we set this to "/assets/static". Files in this directory
// will be copied to `paths.public`, which is "priv/static" by default.
assets: /^(static)/
},
// Phoenix paths configuration
paths: {
// Dependencies and current project directories to watch
watched: ["static", "css", "js", "vendor"],
// Where to compile files to
public: "../priv/static"
},
// Configure your plugins
plugins: {
babel: {
presets: ["es2015", "react"],
// Do not use ES6 compiler in vendor code
ignore: [/vendor/]
}
},
modules: {
autoRequire: {
"js/app.js": ["js/app"]
}
},
npm: {
enabled: true,
whitelist: ["phoenix", "phoenix_html", "react", "react-dom"]
}
};

1
assets/css/app.css Normal file
View file

@ -0,0 +1 @@
/* This file is for your main application css. */

View file

@ -0,0 +1,7 @@
import * as AbsintheSocket from "@absinthe/socket";
import { createAbsintheSocketLink } from "@absinthe/socket-apollo-link";
import { Socket as PhoenixSocket } from "phoenix";
export default createAbsintheSocketLink(AbsintheSocket.create(
new PhoenixSocket("ws://localhost:4000/socket")
));

43
assets/js/app.js Normal file
View file

@ -0,0 +1,43 @@
// Brunch automatically concatenates all files in your
// watched paths. Those paths can be configured at
// config.paths.watched in "brunch-config.js".
//
// However, those files will only be executed if
// explicitly imported. The only exception are files
// in vendor, which are never wrapped in imports and
// therefore are always executed.
// Import dependencies
//
// If you no longer want to use a dependency, remember
// to also remove its path from "config.paths.watched".
import "phoenix_html";
// Import local files
//
// Local files can be imported directly using relative
// paths "./socket" or full ones "web/static/js/socket".
// import socket from "./socket"
import React from "react";
import ReactDOM from "react-dom";
import { ApolloProvider } from 'react-apollo';
import Header from "./components/Header";
import Gallery from "./components/Gallery";
import client from "./client";
class App extends React.Component {
render() {
return (
<ApolloProvider client={client}>
<div className="container">
<Header />
<Gallery />
</div>
</ApolloProvider>
);
}
}
ReactDOM.render(<App />, document.getElementById("face-gallery"));

10
assets/js/client.js Normal file
View file

@ -0,0 +1,10 @@
import { ApolloClient } from "apollo-client";
import { InMemoryCache } from "apollo-cache-inmemory";
import absintheSocketLink from "./absinthe-socket-link";
const client = new ApolloClient({
link: absintheSocketLink,
cache: new InMemoryCache(),
});
export default client;

View file

@ -0,0 +1,65 @@
import React from "react";
import gql from "graphql-tag";
import { Mutation } from "react-apollo";
import IMPORT_PERSON from "../queries/import_person";
import LIST_PEOPLE from "../queries/list_people";
class AddFace extends React.Component {
// I extracted the cache updating logic from the JSX because there were too
// many nested braces and it was making me confused.
cacheUpdater(cache, { data: { importPerson } }) {
let { people } = cache.readQuery({ query: LIST_PEOPLE });
// If the person already exists in the cache then replace them with the new data.
let existingPersonIndex = people.findIndex(person => person.username === importPerson.username);
if (existingPersonIndex >= 0) {
people.splice(existingPersonIndex, 1, importPerson);
}
// Otherwise add them to the cache.
else {
people = people.concat([importPerson]);
}
cache.writeQuery({
query: LIST_PEOPLE,
data: { people: people }
});
}
render() {
let input;
return (
<Mutation
mutation={IMPORT_PERSON}
update={this.cacheUpdater}>
{(importPerson, { data }) => (
<div className="col-sm-3 mb-3">
<div className="card">
<div className="card-header">
<h5 className="card-title">Add Face</h5>
</div>
<div className="card-body">
<form onSubmit={e => {
e.preventDefault();
importPerson({ variables: { username: input.value } });
input.value = "";
}}>
<div className="form-group">
<label htmlFor="username">Github Username</label>
<input type="text" name="username" className="form-control" ref={node => input = node} />
</div>
<input type="submit" className="btn btn-primary" value="Add" />
</form>
</div>
</div>
</div>
)}
</Mutation>
);
}
}
export default AddFace;

View file

@ -0,0 +1,49 @@
import React from "react";
import gql from "graphql-tag";
import { Query } from "react-apollo";
import Person from "./Person";
import AddFace from "./AddFace";
import LIST_PEOPLE from "../queries/list_people";
import PERSON_ADDED from "../queries/person_added";
class Gallery extends React.Component {
// Uses Apollo's `subscribeToMore` callback to subscribe to the `personAdded`
// subscription and update our local data with the new data.
doSubscription(subscribeToMore) {
subscribeToMore({
document: PERSON_ADDED,
updateQuery: ({ people }, { subscriptionData }) => {
let newPerson = subscriptionData.data.personAdded;
let existingPersonIndex = people.findIndex(person => person.username === newPerson.username);
if (existingPersonIndex >= 0) {
people.splice(existingPersonIndex, 1, newPerson);
}
else {
people = people.concat([newPerson]);
}
return { people };
}
});
}
render() {
return (
<div className="row">
<Query query={LIST_PEOPLE}>
{({ subscribeToMore, loading, data }) => {
if (loading) return null;
this.doSubscription(subscribeToMore);
return data.people.map((person, key) => (<Person person={person} key={key} />));
}}
</Query>
<AddFace />
</div>
);
}
}
export default Gallery;

View file

@ -0,0 +1,15 @@
import React from "react";
class Header extends React.Component {
render() {
return (
<div className="row">
<div className="col-sm">
<h1>Face Gallery</h1>
</div>
</div>
);
}
}
export default Header;

View file

@ -0,0 +1,22 @@
import React from "react";
class Person extends React.Component {
render() {
const person = this.props.person;
const githubUrl = `https://github.com/${person.username}`;
return (
<div className="col-sm-3 mb-3">
<div className="card">
<img className="card-img-top" src={person.avatarUrl} alt={person.name} />
<div className="card-body">
<a href={githubUrl}><h5 className="card-title">{person.name}</h5></a>
<p className="card-text">{person.location}</p>
</div>
</div>
</div>
);
}
}
export default Person;

View file

@ -0,0 +1,12 @@
import gql from "graphql-tag";
export default gql `
mutation importPerson($username: String!) {
importPerson(username: $username) {
username,
name,
location,
avatarUrl
}
}
`;

View file

@ -0,0 +1,12 @@
import gql from "graphql-tag";
export default gql `
query {
people{
username,
name,
location,
avatarUrl
}
}
`;

View file

@ -0,0 +1,12 @@
import gql from "graphql-tag";
export default gql`
subscription personAdded {
personAdded {
username,
name,
location,
avatarUrl
}
}
`;

62
assets/js/socket.js Normal file
View file

@ -0,0 +1,62 @@
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "assets/js/app.js".
// To use Phoenix channels, the first step is to import Socket
// and connect at the socket path in "lib/web/endpoint.ex":
import {Socket} from "phoenix"
let socket = new Socket("/socket", {params: {token: window.userToken}})
// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
// which authenticates the session and assigns a `:current_user`.
// If the current user exists you can assign the user's token in
// the connection for use in the layout.
//
// In your "lib/web/router.ex":
//
// pipeline :browser do
// ...
// plug MyAuth
// plug :put_user_token
// end
//
// defp put_user_token(conn, _) do
// if current_user = conn.assigns[:current_user] do
// token = Phoenix.Token.sign(conn, "user socket", current_user.id)
// assign(conn, :user_token, token)
// else
// conn
// end
// end
//
// Now you need to pass this token to JavaScript. You can do so
// inside a script tag in "lib/web/templates/layout/app.html.eex":
//
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
//
// You will need to verify the user token in the "connect/2" function
// in "lib/web/channels/user_socket.ex":
//
// def connect(%{"token" => token}, socket) do
// # max_age: 1209600 is equivalent to two weeks in seconds
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
// {:ok, user_id} ->
// {:ok, assign(socket, :user, user_id)}
// {:error, reason} ->
// :error
// end
// end
//
// Finally, pass the token on connect as below. Or remove it
// from connect if you don't care about authentication.
socket.connect()
// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("topic:subtopic", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
export default socket

34
assets/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"repository": {},
"license": "MIT",
"scripts": {
"deploy": "brunch build --production",
"watch": "brunch watch --stdin"
},
"dependencies": {
"@absinthe/socket-apollo-link": "^0.1.11",
"apollo-client-preset": "^1.0.8",
"babel-preset-react": "^6.24.1",
"graphql": "^0.13.2",
"graphql-tag": "^2.8.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"react": "^16.3.1",
"react-apollo": "^2.1.3",
"react-dom": "^16.3.1",
"yarn": "^1.5.1"
},
"devDependencies": {
"babel-brunch": "6.1.1",
"brunch": "2.10.9",
"clean-css-brunch": "2.10.0",
"eslint": "^4.19.1",
"eslint-config-standard": "^11.0.0",
"eslint-plugin-import": "^2.10.0",
"eslint-plugin-node": "^6.0.1",
"eslint-plugin-promise": "^3.7.0",
"eslint-plugin-react": "^7.7.0",
"eslint-plugin-standard": "^3.0.1",
"uglify-js-brunch": "2.10.0"
}
}

BIN
assets/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

5
assets/static/robots.txt Normal file
View file

@ -0,0 +1,5 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

4014
assets/yarn.lock Normal file

File diff suppressed because it is too large Load diff

29
config/config.exs Normal file
View file

@ -0,0 +1,29 @@
# 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
# General application configuration
config :faces, ecto_repos: [Faces.Repo]
# Configures the endpoint
config :faces, FacesWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "ThLNldzPhX4I3g7eg79qoWrA1dj48zfsJBwbivAgNXq3XxhIo8tYh7jtteExdh0N",
render_errors: [view: FacesWeb.ErrorView, accepts: ~w(html json)],
pubsub: [name: Faces.PubSub, adapter: Phoenix.PubSub.PG2]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:user_id]
config :faces, Faces.Repo, migration_timestamps: [type: :utc_datetime]
config :faces, Faces.Gallery.GithubUserData, access_token: System.get_env("GITHUB_ACCESS_TOKEN")
# 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"

58
config/dev.exs Normal file
View file

@ -0,0 +1,58 @@
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 :faces, FacesWeb.Endpoint,
http: [port: 4000],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin",
cd: Path.expand("../assets", __DIR__)]]
# ## 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.
# Watch static and templates for browser reloading.
config :faces, FacesWeb.Endpoint,
live_reload: [
patterns: [
~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
~r{priv/gettext/.*(po)$},
~r{lib/faces_web/views/.*(ex)$},
~r{lib/faces_web/templates/.*(eex)$}
]
]
# 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
# Configure your database
config :faces, Faces.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "faces_dev",
hostname: "localhost",
pool_size: 10

64
config/prod.exs Normal file
View file

@ -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
# FacesWeb.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 :faces, FacesWeb.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 :faces, FacesWeb.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 :faces, FacesWeb.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 :faces, FacesWeb.Endpoint, server: true
#
# Finally import the config/prod.secret.exs
# which should be versioned separately.
import_config "prod.secret.exs"

19
config/test.exs Normal file
View file

@ -0,0 +1,19 @@
use Mix.Config
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :faces, FacesWeb.Endpoint,
http: [port: 4001],
server: false
# Print only warnings and errors during test
config :logger, level: :warn
# Configure your database
config :faces, Faces.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "faces_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox

3
go.sh Executable file
View file

@ -0,0 +1,3 @@
set -e
echo "All done."

9
lib/faces.ex Normal file
View file

@ -0,0 +1,9 @@
defmodule Faces do
@moduledoc """
Faces 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

33
lib/faces/application.ex Normal file
View file

@ -0,0 +1,33 @@
defmodule Faces.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 Ecto repository
supervisor(Faces.Repo, []),
# Start the endpoint when the application starts
supervisor(FacesWeb.Endpoint, []),
# Start your own worker by calling: Faces.Worker.start_link(arg1, arg2, arg3)
# worker(Faces.Worker, [arg1, arg2, arg3]),
supervisor(Absinthe.Subscription, [FacesWeb.Endpoint]),
worker(Faces.Gallery.EventListener, [])
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Faces.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
FacesWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View file

@ -0,0 +1,57 @@
defmodule Faces.Gallery.EventListener do
use GenServer
require Logger
alias Absinthe.Subscription
@notification_channel "people_changes"
def start_link, do: GenServer.start_link(__MODULE__, [])
def init(_opts) do
pg_config = Faces.Repo.config()
{:ok, pid} = Postgrex.Notifications.start_link(pg_config)
{:ok, ref} = Postgrex.Notifications.listen(pid, @notification_channel)
Logger.debug(fn -> "#{__MODULE__} subscribed to channel #{@notification_channel}" end)
{:ok, {pid, @notification_channel, ref}}
end
def handle_info({:notification, _pid, _ref, @notification_channel, payload}, _state) do
with {:ok, event} <- Poison.decode(payload),
{:ok, person} <- strip_person_from_event(event),
:ok <- publish_via_absinthe(person) do
{:noreply, :event_handled}
else
_error -> {:noreply, :event_received}
end
end
def handle_info(_, _state), do: {:noreply, :event_received}
defp strip_person_from_event(%{"new_row_data" => row_data}) do
person =
row_data
|> Map.take(["id", "username", "name", "location", "avatar_url"])
|> Map.put("inserted_at", cooerce_time(Map.get(row_data, "inserted_at")))
|> Map.put("updated_at", cooerce_time(Map.get(row_data, "updated_at")))
|> Enum.reduce(%{}, fn {key, value}, map -> Map.put(map, String.to_atom(key), value) end)
{:ok, person}
end
defp strip_person_from_event(_), do: {:error, "Invalid event data"}
defp publish_via_absinthe(person) do
Subscription.publish(FacesWeb.Endpoint, person, person_added: "person_added")
end
defp cooerce_time(nil), do: DateTime.utc_now()
defp cooerce_time(iso8601) do
with {:ok, ndt} <- NaiveDateTime.from_iso8601(iso8601),
{:ok, dt} <- DateTime.from_naive(ndt, "Etc/UTC") do
dt
else
{:error, reason} -> {:error, reason}
end
end
end

View file

@ -0,0 +1,23 @@
defmodule Faces.Gallery do
@moduledoc """
The Gallery context.
"""
alias Faces.Repo
alias Faces.Gallery.{Person, Importer}
@doc """
Returns the list of people.
## Examples
iex> list_people()
[%Person{}, ...]
"""
def list_people do
Repo.all(Person)
end
def import_user(username), do: Importer.import(username)
end

View file

@ -0,0 +1,57 @@
defmodule Faces.Gallery.GithubUserData do
alias Tentacat.{Client, Users}
@doc """
Retrieves a user's information from GitHub.
## Examples
iex> GitHubUserData.get("jamesotron")
{:ok, %{
"avatar_url" => "https://avatars2.githubusercontent.com/u/59449?v=4",
"location" => "Wellington, New Zealand",
"name" => "James Harton",
"username" => "jamesotron"
}}
iex> GitHubUserData.get("thisUserReallyDoesntExist")
{:error, "404 while retrieving \"thisUserReallyDoesntExist\" from Github: Not Found"}
"""
def get(username) do
with {200, user_data, _} <- get_user_from_github(username),
{:ok, user_data} <- just_the_facts(user_data),
{:ok, user_data} <- add_username(username, user_data) do
{:ok, user_data}
else
{:error, reason} ->
{:error, reason}
{i, %{"message" => message}, _} when is_integer(i) ->
{:error, "#{i} while retrieving #{inspect(username)} from Github: #{message}"}
{i, _, _} when is_integer(i) ->
{:error, "#{i} while retrieving #{inspect(username)} from Github"}
end
end
defp get_user_from_github(username) do
username
|> Users.find(github_client())
end
defp github_client, do: Client.new(%{access_token: github_access_token()})
defp github_access_token do
:faces
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:access_token)
end
defp just_the_facts(user_data) do
{:ok, Map.take(user_data, ["avatar_url", "name", "location"])}
end
defp add_username(username, user_data) do
{:ok, Map.put(user_data, "username", username)}
end
end

View file

@ -0,0 +1,23 @@
defmodule Faces.Gallery.Importer do
alias Faces.Gallery.{Person, GithubUserData}
alias Faces.Repo
def import(username) do
with {:ok, user_data} <- GithubUserData.get(username),
{:ok, changeset} <- generate_changeset(user_data),
{:ok, person} <- upsert(changeset) do
{:ok, person}
else
{:error, reason} -> {:error, reason}
end
end
defp generate_changeset(user_data) do
{:ok, Person.changeset(%Person{}, user_data)}
end
defp upsert(changeset) do
changeset
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:username])
end
end

View file

@ -0,0 +1,22 @@
defmodule Faces.Gallery.Person do
use Ecto.Schema
import Ecto.Changeset
@timestamps_opts [type: :utc_datetime, usec: true]
schema "people" do
field(:username, :string)
field(:avatar_url, :string)
field(:location, :string)
field(:name, :string)
timestamps()
end
@doc false
def changeset(person, attrs) do
person
|> cast(attrs, [:username, :name, :location, :avatar_url])
|> validate_required([:username, :name, :avatar_url])
end
end

11
lib/faces/repo.ex Normal file
View file

@ -0,0 +1,11 @@
defmodule Faces.Repo do
use Ecto.Repo, otp_app: :faces
@doc """
Dynamically loads the repository url from the
DATABASE_URL environment variable.
"""
def init(_, opts) do
{:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
end
end

67
lib/faces_web.ex Normal file
View file

@ -0,0 +1,67 @@
defmodule FacesWeb 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 FacesWeb, :controller
use FacesWeb, :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: FacesWeb
import Plug.Conn
import FacesWeb.Router.Helpers
import FacesWeb.Gettext
end
end
def view do
quote do
use Phoenix.View, root: "lib/faces_web/templates",
namespace: FacesWeb
# Import convenience functions from controllers
import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
import FacesWeb.Router.Helpers
import FacesWeb.ErrorHelpers
import FacesWeb.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 FacesWeb.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

View file

@ -0,0 +1,38 @@
defmodule FacesWeb.UserSocket do
use Phoenix.Socket
use Absinthe.Phoenix.Socket, schema: FacesWeb.Schema
## Channels
# channel "room:*", FacesWeb.RoomChannel
## 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:
#
# FacesWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
#
# Returning `nil` makes this socket anonymous.
def id(_socket), do: nil
end

View file

@ -0,0 +1,8 @@
defmodule FacesWeb.FaceController do
use FacesWeb, :controller
alias Faces.Gallery
def index(conn, _params) do
render(conn, "index.html")
end
end

65
lib/faces_web/endpoint.ex Normal file
View file

@ -0,0 +1,65 @@
defmodule FacesWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :faces
use Absinthe.Phoenix.Endpoint
socket("/socket", FacesWeb.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: :faces,
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
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
end
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: "_faces_key",
signing_salt: "SBTuT23J"
)
plug(FacesWeb.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

24
lib/faces_web/gettext.ex Normal file
View file

@ -0,0 +1,24 @@
defmodule FacesWeb.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 FacesWeb.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: :faces
end

View file

@ -0,0 +1,30 @@
defmodule FacesWeb.Resolvers.People do
alias Faces.Gallery
@doc """
This is the resolver callback for Absinthe to find a list of all people.
The arguments are:
* `parent`, any parent object which Absinthe things we're related to.
* `args`, any arguments passed to the query.
* `resolution`,
"""
def list_people(_parent, _args, _resolution) do
{:ok, Gallery.list_people()}
end
@doc """
This is the resolver callback Absinthe uses to create a person.
The arguments are:
* `parent` any parent object which Absinthe things we're related to.
* `args` a map of arguments passed to the query.
* `context` a context object which can be used for things like
authentication, etc.
"""
def create_person(_parent, %{username: username}, _context) do
Gallery.import_user(username)
end
end

29
lib/faces_web/router.ex Normal file
View file

@ -0,0 +1,29 @@
defmodule FacesWeb.Router do
use FacesWeb, :router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
pipeline :api do
plug(:accepts, ["json"])
end
scope "/", FacesWeb do
# Use the default browser stack
pipe_through(:browser)
get("/", FaceController, :index)
end
# Other scopes may use custom stacks.
# scope "/api", FacesWeb do
# pipe_through :api
# end
forward("/api", Absinthe.Plug, schema: FacesWeb.Schema)
forward("/graphiql", Absinthe.Plug.GraphiQL, schema: FacesWeb.Schema)
end

View file

@ -0,0 +1,27 @@
defmodule FacesWeb.Schema.Person do
use Absinthe.Schema.Notation
@desc "A person whose face we want to see"
object :person do
@desc "A unique identifier for this person"
field(:id, :id)
@desc "The person's Github username"
field(:username, :string)
@desc "The person's name as per Github"
field(:name, :string)
@desc "The person's location as per Github"
field(:location, :string)
@desc "The URL of the person's Github avatar image"
field(:avatar_url, :string)
@desc "When this user was first imported into the faces app"
field(:inserted_at, :datetime)
@desc "When this user was last updated in the faces app"
field(:updated_at, :datetime)
end
end

View file

@ -0,0 +1,33 @@
defmodule FacesWeb.Schema do
use Absinthe.Schema
alias FacesWeb.Resolvers
import_types(Absinthe.Type.Custom)
import_types(FacesWeb.Schema.Person)
query do
@desc "List all people"
field :people, list_of(:person) do
resolve(&Resolvers.People.list_people/3)
end
end
mutation do
@desc "Import a user from Github"
field :import_person, type: :person do
arg(:username, non_null(:string))
resolve(&Resolvers.People.create_person/3)
end
end
subscription do
@desc "New person added"
field :person_added, :person do
config(fn _, _ ->
{:ok, topic: "person_added"}
end)
trigger(:import_person, topic: fn _ -> "person_added" end)
end
end
end

View file

@ -0,0 +1 @@
<div id="face-gallery"></div>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>Hello Faces!</title>
<link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
</head>
<body>
<%= render @view_module, @view_template, assigns %>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,44 @@
defmodule FacesWeb.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn (error) ->
content_tag :span, translate_error(error), class: "help-block"
end)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate "is invalid" in the "errors" domain
# dgettext "errors", "is invalid"
#
# # Translate the number of files with plural rules
# dngettext "errors", "1 file", "%{count} files", count
#
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
# This requires us to call the Gettext module passing our gettext
# backend as first argument.
#
# Note we use the "errors" domain, which means translations
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(FacesWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(FacesWeb.Gettext, "errors", msg, opts)
end
end
end

View file

@ -0,0 +1,16 @@
defmodule FacesWeb.ErrorView do
use FacesWeb, :view
# If you want to customize a particular status code
# for a certain format, you may uncomment below.
# def render("500.html", _assigns) do
# "Internal Server Error"
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.html" becomes
# "Not Found".
def template_not_found(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View file

@ -0,0 +1,3 @@
defmodule FacesWeb.FaceView do
use FacesWeb, :view
end

View file

@ -0,0 +1,3 @@
defmodule FacesWeb.LayoutView do
use FacesWeb, :view
end

64
mix.exs Normal file
View file

@ -0,0 +1,64 @@
defmodule Faces.Mixfile do
use Mix.Project
def project do
[
app: :faces,
version: "0.0.1",
elixir: "~> 1.4",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Faces.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.2"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.10", override: true},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:tentacat, "~> 0.9.0"},
{:absinthe_plug, "~> 1.4"},
{:poison, "~> 2.1.0", override: true},
{:absinthe_phoenix, "~> 1.4.0"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to create, migrate and run the seeds file at once:
#
# $ mix ecto.setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
end

36
mix.lock Normal file
View file

@ -0,0 +1,36 @@
%{
"absinthe": {:hex, :absinthe, "1.4.10", "9f8d0c34dfcfd0030d3a3f123c7501e99ab59651731387289dad5885047ebb2a", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.2", "cb84c81b94103fdfbbdd8b83b7b4b70a850ab7d3be6a1e56b96de2bb854b09b6", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.2", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.10.5", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poison, "~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"absinthe_plug": {:hex, :absinthe_plug, "1.4.2", "01bf16f0a637869bcc0a1919935f08ff853501004e7549ddaa3a7788deb48965", [:mix], [{:absinthe, "~> 1.4", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{: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", [:make], [], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.2.9", "031d55df9bb430cb118e6f3026a87408d9ce9638737bda3871e5d727a3594aae", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.4", "f0bdda195c0e46e987333e986452ec523aed21d784189144f647c43eaf307064", [:mix], [], "hexpm"},
"gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"},
"hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "0.13.0", "bfaf44d9f133a6599886720f3937a7699466d23bb0cd7a88b6ba011f53c6f562", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.3.2", "2a00d751f51670ea6bc3f2ba4e6eb27ecb8a2c71e7978d9cd3e5de5ccf7378bd", [:mix], [{: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_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_html": {:hex, :phoenix_html, "2.11.1", "77b6f7fbd252168c6ec4f573de648d37cc5258cda13266ef001fbf99267eb6f3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.3", "1d178429fc8950b12457d09c6afec247bfe1fcb6f36209e18fbb0221bdfe4d41", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.5.0", "224b25b4039bedc1eac149fb52ed456770b9678bbf0349cdd810460e1e09195b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"},
"poison": {:hex, :poison, "2.1.0", "f583218ced822675e484648fa26c933d621373f01c6c76bd00005d7bd4b82e27", [:mix], [], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"},
"tentacat": {:hex, :tentacat, "0.9.0", "c773d6d3def1a37296330c2787549c0f0f507f45b3a580d32d7d8aa3fdd56d3f", [:mix], [{:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 0.8", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
}

View file

@ -0,0 +1,97 @@
## `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"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

95
priv/gettext/errors.pot Normal file
View file

@ -0,0 +1,95 @@
## 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.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

View file

@ -0,0 +1,16 @@
defmodule Faces.Repo.Migrations.CreatePeople do
use Ecto.Migration
def change do
create table(:people) do
add(:username, :string)
add(:name, :string)
add(:location, :string)
add(:avatar_url, :string)
timestamps()
end
create(index(:people, [:username], unique: true))
end
end

View file

@ -0,0 +1,47 @@
defmodule Faces.Repo.Migrations.BroadcastPeopleTableChanges do
use Ecto.Migration
def up do
# Create a function that broadcasts row changes
execute("
CREATE OR REPLACE FUNCTION broadcast_changes()
RETURNS trigger AS $$
DECLARE
current_row RECORD;
BEGIN
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
current_row := NEW;
ELSE
current_row := OLD;
END IF;
IF (TG_OP = 'INSERT') THEN
OLD := NEW;
END IF;
PERFORM pg_notify(
'people_changes',
json_build_object(
'table', TG_TABLE_NAME,
'type', TG_OP,
'id', current_row.id,
'new_row_data', row_to_json(NEW),
'old_row_data', row_to_json(OLD)
)::text
);
RETURN current_row;
END;
$$ LANGUAGE plpgsql;")
# Create a trigger links the people table to the broadcast function
execute("
CREATE TRIGGER notify_people_changes_trigger
AFTER INSERT OR UPDATE
ON people
FOR EACH ROW
EXECUTE PROCEDURE broadcast_changes();")
end
def down do
execute("DROP TRIGGER IF EXISTS notify_people_changes_trigger ON people;")
execute("DROP FUNCTION IF EXISTS broadcast_changes;")
end
end

17
priv/repo/seeds.exs Normal file
View file

@ -0,0 +1,17 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Faces.Repo.insert!(%Faces.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Faces.Gallery
Gallery.import_user("jamesotron")
Gallery.import_user("terrcin")
Gallery.import_user("edgurgel")

View file

@ -0,0 +1,4 @@
defmodule Faces.GalleryTest do
use Faces.DataCase
alias Faces.Gallery
end

View file

@ -0,0 +1,5 @@
defmodule FacesGalleryGithubUserDataTest do
use ExUnit.Case
alias Faces.Gallery.GithubUserData
doctest Faces.Gallery.GithubUserData
end

View file

@ -0,0 +1,8 @@
defmodule FacesWeb.PageControllerTest do
use FacesWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get conn, "/"
assert html_response(conn, 200) =~ "Welcome to Phoenix!"
end
end

View file

@ -0,0 +1,16 @@
defmodule FacesWeb.ErrorViewTest do
use FacesWeb.ConnCase, async: true
# Bring render/3 and render_to_string/3 for testing custom views
import Phoenix.View
test "renders 404.html" do
assert render_to_string(FacesWeb.ErrorView, "404.html", []) ==
"Not Found"
end
test "renders 500.html" do
assert render_to_string(FacesWeb.ErrorView, "500.html", []) ==
"Internal Server Error"
end
end

View file

@ -0,0 +1,3 @@
defmodule FacesWeb.LayoutViewTest do
use FacesWeb.ConnCase, async: true
end

View file

@ -0,0 +1,3 @@
defmodule FacesWeb.PageViewTest do
use FacesWeb.ConnCase, async: true
end

View file

@ -0,0 +1,37 @@
defmodule FacesWeb.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 FacesWeb.Endpoint
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Faces.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Faces.Repo, {:shared, self()})
end
:ok
end
end

38
test/support/conn_case.ex Normal file
View file

@ -0,0 +1,38 @@
defmodule FacesWeb.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 FacesWeb.Router.Helpers
# The default endpoint for testing
@endpoint FacesWeb.Endpoint
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Faces.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Faces.Repo, {:shared, self()})
end
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

53
test/support/data_case.ex Normal file
View file

@ -0,0 +1,53 @@
defmodule Faces.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
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
alias Faces.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Faces.DataCase
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Faces.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Faces.Repo, {:shared, self()})
end
:ok
end
@doc """
A helper that transform changeset errors to a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end

4
test/test_helper.exs Normal file
View file

@ -0,0 +1,4 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Faces.Repo, :manual)