Implement project for Software Developer assessment

This commit is contained in:
Nat 2022-06-04 12:03:36 -07:00
parent 4392c1a29f
commit 6a842f4d58
55 changed files with 12604 additions and 0 deletions

148
README.md Normal file
View File

@ -0,0 +1,148 @@
# Cat Bookmarker
My submission for the software developer assessment consumes JSON
representations of cat images accessible via the [Cataas](https://cataas.com)
API, the links to which can be permanetly stored in a bookmark database.
## Details
The form submits four pieces of data:
- `category`: The name of the category the bookmark will be stored under
- `notes`: Any extra details, stored as a single string
- `remote_id`: The ID of the resource accessible via Cataas' API
` `creator`: A pseudo-anonymous integer ID representing the actor bookmarking the image
The first two are literally HTML input elements. The third is selected randomly by repeatedly pressing the "Show New Cat" button. The third is represented by a route both in Phoenix and using React Router.
The tech stack for this project is as follows:
- Elixir, the back-end programming language
- Phoenix, which is the Ruby On Rails of Elixir
- PostgreSQL as the database
- React for the front-end
The front-end of this project is a single-page app that uses React Router to navigate between the two "pages."
All of the API endpoints are under the `/api/v1/` route, and since this project only really does bookmarking, all of them ended up being under `/api/v1/bookmark`. The three required routes are as followes, followed by the other routes I added for convenience:
- POST /api/v1/bookmark - Accepts a JSON payload with keys `remote_id`, `notes`, and `category` and stores it in the database.
- GET /api/v1/bookmark/single/:id - Returns a single bookmark with the given ID. If The user doesn't have any bookmarks with that ID, the server returns an error with a 404 status code.
- GET /api/v1/bookmark - Returns all of the user's bookmarks.
- DELETE /api/v1/bookmark/:id - Deletes the bookmark
- GET /api/v1/bookmark/category/:category - Returns all bookmarks in `:category`
- GET /api/v1/bookmark/categories - Returns an array of strings, each representing the name of a category that has at least one bookmark
### Rudimentary authentication
Accounts are generated in the same way some conferencing software gives users semi-private meeting rooms represented by a random string of words. That way, anyone with the link can access it, but it's not particularly easy to find a given room without knowing the string of words in advance.
I didn't feel like putting together a large dictionary of words, so I just used JavaScript's pseudo-random number generator to generate a probably-unique user ID (or at least, there's only a one in a million chance of a clash).
The user's page is accessible by accessing the `/u/:id` route, where `:id` is the user's ID number
When making calls to the API, the client passes an `Authorization` header to their request including their ID, such as `Authorization: Basic 1234...`. If this header is omitted or malformed, the server returns a 401 status code. Not production-grade by any meanse, but not bad for a cat bookmarking app.
## Running the server
To run the server, you'll need Elixir installed on your device, which depends
on Erlang. Detailed instructions on how to install Elixir and Erlang can be
found on [the Elixir website](https://elixir-lang.org/install.html). In most
cases, installing Elixir will get you a copy of Erlang as well.
Additionally, you'll need to have PostgreSQL installed and running. You can
find information on installing PostgreSQL on
[it's website](https://www.postgresql.org/download/) as well. This project is
set up to assume that the database username is "postgres" and the password is
"postgres"
```bash
$ cd bookmarker
$ mix deps.get # Install Elixir dependencies
$ mix ecto.migrate # Set up the database
$ cd assets
$ npm install # Install Node dependencies
$ cd ..
$ mix phx.server # Run the server
```
You can then navigate to localhost:4000 to find the front end of the
application.
## Reading the code
Phoenix automatically generates a lot of stuff that isn't really relevant to
this project, so there's really only a few places you need to focus on.
- bookmarker/
- lib/ - All of the back-end code
- bookmarker/bookmarker.ex - the description of the database entries
- bookmarker_web/
- router.ex - The routes for the API and UI
- controllers/app_controller.ex - The functions that control what
happens when a client accesses a route in the UI
- controllers/api_controller.ex - The functions that control what
happens when a client accesses a route in the API
- assets/ - All of the front-end code
- js/app.js - The main JavaScript file containing the root React
component and routing information
- js/home.js - The home page component
- js/bookmarks.js - The component for the page used to bookmark cats
and review bookmarks by category
- css/app.css - All of the application's styles
## Possible improvements
### Security
In some places, API requests to resources not owned by the user will return a
message along the lines of "you cannot access other people's resources." It
would probably be a better practice to simply return a 404 status code and tell
the client the resource cannot be found in these places, as implying that the
client has stumbled upon another user's resources could potentially be
compromising.
The decision to use integers to represent users was arbitrary. I very well could
have used alpha-numeric strings or, even better, a series of four or five random
words. With a large enough dictionary, using a string of words would improve
security and memorability.
That being said, the biggest issue with using Authorization headers is that if
the connection isn't being made over HTTPS, then confidential information is
being traded in plaintext. Either additional steps would have to be taken to
prevent snooping or an alternative, more rigorous method of user-management
would have to be put into effect to ensure the security of everyone's cat
bookmarks.
### Efficiency
The front-end being a single-page app presents some issues. Particularly, you
can't directly access `localhost:4000/u/:id` without confusing Phoenix, because
`/u/:id` isn't a real route. To get around this, I made the hack-ish decision
to redirect that route to `/?u=:id` and then have the Home component listen for
query parameters before redirecting the client again to the page they're looking
for. While this works, it's kind of gross and inefficient. There isn't really
any need for this project to be a single-page app. It'd perhaps do even better
with a separate page for the two routes.
In a production environment, more thought would need to go into how many
connections to the database ought to be pooled to prevent a bottleneck.
React is a fairly big blob of JavaScript to use in such a simple project. I
could probably do away with it entirely if I was willing to put up with vanilla
JS, or use a lightweight alternative like Preact. Alternatively, I could have
also chosen to do more on the server-side instead of leaving everything short of
the API up to the client.
### Functional improvements
Some functions that control what happens when a client navigates to a route do
dangerous actions, such as ignoring potential errors that seem unlikely instead
of handling them. This code should be replaced with better error-handling
mechanisms on the off-chance that they do actually come up.
There should be more custom error pages implemented, particularly in the case
of the user-facing interface. The user should never be presented with a complete
stack trace, for example.
## Deployment considerations
Elixir is well known for being able to accomplish quite a bit with limited
resources. Many Phoenix projects run just as well on a Raspberry PI as they do
on any other device. However, as someone who's tried to self-host services built
using the Phoenix framework, I find that managing the dependencies can be a bit
of a nightmare. That's one major advantage that the Express/Node pairing has
over Phoenix and Elixir: it's everywhere. You'd be hard pressed to find a
developer who doesn't have Node installed on their computer.
In cases like this, I like to look for containerized versions of the services,
usually through Docker. It's much easier to manage that way.

View File

@ -0,0 +1,5 @@
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"]
]

34
bookmarker/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
bookmarker-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

19
bookmarker/README.md Normal file
View File

@ -0,0 +1,19 @@
# Bookmarker
To start your Phoenix server:
* Install dependencies with `mix deps.get`
* Create and migrate your database with `mix ecto.setup`
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S 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](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix

View File

@ -0,0 +1,14 @@
module.exports = function (api) {
api.cache(true);
const presets = [
"@babel/preset-env'",
"@babel/preset-react",
];
const plugins = [];
return {
presets,
plugins
};
}

View File

@ -0,0 +1,202 @@
/* This file is for your main application CSS */
/*@import "./phoenix.css";*/
.inline { display: inline; }
.center-text { text-align: center; }
.center-box-inner {
margin-left: auto;
margin-right: auto;
}
body {
font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif;
margin: 0;
padding: 0;
}
h1,h2,h3,h4,h5,h6 {
font-family: "Lucida Console", Monaco, monospace;
}
h2 { font-size: 2em; }
img {
height: 50vh;
}
button {
padding: 10px;
margin: 5px;
background-color: #bde0fe;
}
button:hover { background-color: #a2d2ff; }
button.active { background-color: #ffc8dd; }
button.active:hover { background-color: #ffafcc }
.main-container {
width: 70%;
margin-left: auto;
margin-right: auto;
}
.random-image {
display: block;
}
.form-container {
width: 500px;
max-width: 80%;
margin-left: auto;
margin-right: auto;
}
.form-label {
margin-bottom: 5px;
}
input {
font-family: "Lucida Console", Monaco, monospace;
width: 100%;
padding: 10px;
}
button.form-submit-button {
min-width: 40%
}
img.collection-image {
height: auto;
max-width: 75%;
}
p.collection-note {
font-style: italic;
}
.collection-item-caption {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 5px;
margin-bottom: 20px;
}
/* Alerts and form errors used by phx.new */
.alert {
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import "../css/app.css";
import Home from "./home";
import Bookmarks from "./bookmarks";
// Render the root component
const root = ReactDOM.createRoot(
document.getElementById("root")
);
root.render(
<BrowserRouter>
<Routes>
<Route path="/" element = { <Home /> } />
<Route path="u/:id" element = { <Bookmarks /> } />
</Routes>
</BrowserRouter>
);

View File

@ -0,0 +1,243 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
const cataas = (endpoint) => "https://cataas.com" + endpoint;
const bookmarker = (endpoint) => "http://localhost:4000/api/v1" + endpoint;
const Bookmarks = (props) => {
// This gives us access to the :id in /u/:id
const { id } = useParams();
// For standard headers in all Bookmarker requests
const headers = {
"Authorization": "Basic " + id,
};
// The object describing the randomly fetched cat image
const [randomCat, setRandomCat] = useState(null);
// The state of the CreateNewBookmark form
const [formState, setFormState] = useState({
category: "",
notes: "",
});
// The list of all categories that exist in the database
const [categories, setCategories] = useState([]);
// The collection of which posts are being shown
const [activeCategory, setActiveCategory] = useState(null);
const [categoryCollection, setCategoryCollection] = useState([]);
// This gets run once and then never again, doing initial setup for our
// component
useEffect(() => {
// Fetch and render a random cat picture
showNewCat();
// Fetch the list of categories with more than one bookmark
getAllCategories();
}, []);
// When the active collection is changed, do this
useEffect(() => {
(async () => {
// This should only be run if an activeCategory has been selected
if (activeCategory != null) {
const req = await fetch(
bookmarker("/bookmark/category/" + activeCategory),
{ headers }
);
// Update the state with the resultant JSON object
setCategoryCollection(await req.json());
}
})();
}, [activeCategory]);
const showNewCat = async () => {
// First, reset the cat picture. This will briefly rerender the
// paragraph saying that the image is loading
setRandomCat(null);
const req = await fetch(cataas("/cat?json=true"));
setRandomCat(await req.json());
};
const getAllCategories = async () => {
const req = await fetch(
bookmarker("/bookmark/categories"), { headers });
setCategories(await req.json());
};
const chooseCategory = (category) => {
setActiveCategory(category);
}
const bookmarkCat = async () => {
const req = await fetch(bookmarker("/bookmark"), {
method: "POST",
headers: {...headers,
"Content-Type": "application/json"
},
body: JSON.stringify({ ...formState, remote_id: randomCat.id })
});
// Update the category list in case a new one was added
await getAllCategories();
// Reset the form fields
setFormState({
category: "",
notes: "",
});
// Show a new cat
await showNewCat();
};
const removeBookmark = async (id) => {
const req = await fetch(bookmarker("/bookmark/" + id), {
headers,
method: "DELETE",
});
// Filter out the deleted bookmark and then reload the component
setCategoryCollection(categoryCollection.filter(c => c.id != id));
};
return (
<div className = "main-container">
<h2 className = "center-text"> Random Cat </h2>
<div>
<img className = "random-image center-box-inner"
src = { randomCat != null
? cataas(randomCat.url)
: null
}
alt = { randomCat != null
? randomCat.tags.join(" ")
: "Loading cat..."
} />
<div className = "center-text">
<button onClick = { showNewCat }>
Show New Cat
</button>
</div>
</div>
<div className = "form-container">
<h3 className = "center-text">
Like this cat? Bookmark them.
</h3>
<NewBookmarkForm
submitCallback = { bookmarkCat }
setter = { setFormState }
state = { formState }/>
</div>
<h2> Browse your collections </h2>
<SelectCategory
categories = { categories }
activeCategory = { activeCategory }
callback = { chooseCategory }/>
<div style = {{ height: 50 }}></div>
{ activeCategory != null && categoryCollection.length > 0
? <BookmarkCollection
removeCallback = { removeBookmark }
bookmarks = { categoryCollection } />
: <></>
}
</div>
);
};
// A reusable form component that takes a list of objects representing
// input properties and then renders them dynamically
const Form = (props) => {
const updaterFactory = (key) => (e) => props.setter({
...props.state,
[key]: e.target.value,
});
return (
<form onSubmit={ e => e.preventDefault() }>
{
props.inputs.map((data, i) =>
<div key = { i }>
<p className = "form-label"> { data.label } </p>
<input
placeholder = { data.placeholder }
value = { props.state[data.name] }
onChange = { updaterFactory(data.name) }/>
</div>
)
}
<div className = "center-text">
<button className = "form-submit-button"
onClick = { props.submitCallback }>
Submit
</button>
</div>
</form>
);
};
// Renders the Form component for our specific needs in the case of bookmarking
// a cat photo
const NewBookmarkForm = (props) => (
<Form
{ ...props }
inputs = {[
{
name: "category",
label: "Category name",
placeholder: " Something really cool..."
},
{ name: "notes", label: "Notes", placeholder: "Any other notes" }
]}/>
);
// Renders a list of buttons, each representing a category. When the button
// is pressed, the activeCategory will be updated to reflect the category it
// represents
const SelectCategory = ({ categories, activeCategory, callback }) => {
return (
<>
{ categories.length > 0
? categories.map((category, i) =>
<button className = {
category == activeCategory
? "active"
: ""
}
key = { i }
onClick = { () => callback(category) }>
{ category }
</button>
)
: <p>Nothing to show</p>
}
</>
);
};
// Renders a list of photos followed by their notes
const BookmarkCollection = ({
bookmarks,
removeCallback
}) => bookmarks.map((b, i) =>
<div key = { i }>
<img className = "collection-image"
src = { cataas("/cat/" + b.remote_id) } />
<div className = "collection-item-caption">
<button onClick = { () => removeCallback(b.id) }>
🗑
</button>
{ b.notes != null && b.notes.length > 0
? <p className = "collection-note"> { b.notes } </p>
: <></>
}
</div>
</div>
);
export default Bookmarks;

View File

@ -0,0 +1,67 @@
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
const Home = (props) => {
// The randomly generated URL is a side effect and as such, we'll need
// to generate it in a useEffect hook and update this state to have it
// properly rendered in our component
const [randomURL, setRandomURL] = useState(null);
// Used to programatically navigate with React Router
const navigate = useNavigate();
useEffect(() => {
/* If the user navigates to "/?u=[id]", they should be routed to
* the Bookmarks page. We do this because this is a single-page
* app and if the user tries to access /u/[id] directly, they'll
* get a 404 reponse.
*/
// Access the query parameters
const params = new URLSearchParams(window.location.search);
const id = params.get("u");
if (id != null) {
// Navigate to the respective page
navigate("/u/" + id);
}
// Generate the random ID
const randomID = Math.floor(Math.random() * 1000000);
// Store the full random URL so it can be navigated to
setRandomURL("/u/" + randomID);
}, []);
return (
<div className = "main-container">
<h1> Welcome to Cat Bookmarker </h1>
<p>
Cat Bookmarker is a tool that serves random cat images from
the <a href="https://cataas.com">Cataas</a> (Cat As A Service)
API and invites you to bookmark the ones you like so that you
can come back to them later. You can categorize your cat photos
with user-generated tags and you can leave a note to remind
yourself why you liked the cat so much in the first place.
</p>
<p>
Cat Bookmarker features a very rudimentary user authentication
system using randomly generated user IDs and HTTP Authorization
headers. Every number that won't cause an integer overflow is
a valid user that has its own bookmarks and categories.
</p>
{ /* Only render the new URL once it's generated */ }
{ randomURL != null
? <p>
Don't already have an account? Try:<br/>
<Link to={randomURL}>{randomURL}</Link>
</p>
: <></>
}
</div>
);
};
export default Home;

10043
bookmarker/assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"dependencies": {
"@babel/preset-react": "^7.17.12",
"esbuild": "^0.14.42",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"remount": "^0.11.0"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-loader": "^8.2.5",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"webpack": "^5.73.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.1"
}
}

157
bookmarker/assets/vendor/topbar.js vendored Normal file
View File

@ -0,0 +1,157 @@
/**
* @license MIT
* topbar 1.0.0, 2021-01-06
* https://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
progressTimerId,
fadeTimerId,
currentProgress,
showing,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function () {
if (showing) return;
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

View File

@ -0,0 +1,52 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :bookmarker,
ecto_repos: [Bookmarker.Repo]
# Configures the endpoint
config :bookmarker, BookmarkerWeb.Endpoint,
url: [host: "localhost"],
render_errors: [view: BookmarkerWeb.ErrorView, accepts: ~w(html json), layout: false],
pubsub_server: Bookmarker.PubSub,
live_view: [signing_salt: "xiEnk3le"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :bookmarker, Bookmarker.Mailer, adapter: Swoosh.Adapters.Local
# Swoosh API client is needed for adapters other than SMTP.
config :swoosh, :api_client, false
# Configure esbuild (the version is required)
config :esbuild,
version: "0.14.29",
default: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

75
bookmarker/config/dev.exs Normal file
View File

@ -0,0 +1,75 @@
import Config
# Configure your database
config :bookmarker, Bookmarker.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "bookmarker_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# 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 esbuild to bundle .js and .css sources.
config :bookmarker, BookmarkerWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "hw9K/Q5WIB9/t0PTAoMJX5f8+WSPKNF1O308a1I2XPmHac8CikpJLsan6Zlz1fz5",
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch --loader:.js=jsx)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Note that this task requires Erlang/OTP 20 or later.
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.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 :bookmarker, BookmarkerWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/bookmarker_web/(live|views)/.*(ex)$",
~r"lib/bookmarker_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
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

View File

@ -0,0 +1,49 @@
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
# when generating URLs.
#
# Note 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 should run after static files are built and
# before starting your production server.
config :bookmarker, BookmarkerWeb.Endpoint, 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 :bookmarker, BookmarkerWeb.Endpoint,
# ...,
# url: [host: "example.com", port: 443],
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your endpoint, ensuring
# no data is ever sent via http, always redirecting to https:
#
# config :bookmarker, BookmarkerWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.

View File

@ -0,0 +1,83 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/bookmarker start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :bookmarker, BookmarkerWeb.Endpoint, server: true
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
config :bookmarker, Bookmarker.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :bookmarker, BookmarkerWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration:
#
# config :bookmarker, Bookmarker.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

View File

@ -0,0 +1,30 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :bookmarker, Bookmarker.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "bookmarker_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :bookmarker, BookmarkerWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "lKRXrhb7WWuPMcb0B7Fx0cRVXOLX1IvAr2p6DAEGBE0ROu10QBJKwYzIPrOdzDHf",
server: false
# In test we don't send emails.
config :bookmarker, Bookmarker.Mailer, adapter: Swoosh.Adapters.Test
# Print only warnings and errors during test
config :logger, level: :warn
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime

View File

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

View File

@ -0,0 +1,36 @@
defmodule Bookmarker.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
# Start the Ecto repository
Bookmarker.Repo,
# Start the Telemetry supervisor
BookmarkerWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Bookmarker.PubSub},
# Start the Endpoint (http/https)
BookmarkerWeb.Endpoint
# Start a worker by calling: Bookmarker.Worker.start_link(arg)
# {Bookmarker.Worker, arg}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Bookmarker.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
BookmarkerWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View File

@ -0,0 +1,27 @@
defmodule Bookmarker.Bookmark do
use Ecto.Schema
require Protocol
import Ecto.Changeset
# The schema describes what are in our case the columns of the `bookmarks`
# table in the database in a way that makes it easy to go back and forth
# between the two systems
schema "bookmarks" do
field :category, :string
field :creator, :integer
field :notes, :string
field :remote_id, :string
timestamps()
end
@doc """
The changeset is used to validate and sanitize data before it's put into
the database. This project doesn't make much use of changesets
"""
def changeset(bookmark, attrs) do
bookmark
|> cast(attrs, [:creator, :category, :remote_id, :notes])
|> validate_required([:creator, :category, :remote_id, :notes])
end
end

View File

@ -0,0 +1,3 @@
defmodule Bookmarker.Mailer do
use Swoosh.Mailer, otp_app: :bookmarker
end

View File

@ -0,0 +1,5 @@
defmodule Bookmarker.Repo do
use Ecto.Repo,
otp_app: :bookmarker,
adapter: Ecto.Adapters.Postgres
end

View File

@ -0,0 +1,110 @@
defmodule BookmarkerWeb 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 BookmarkerWeb, :controller
use BookmarkerWeb, :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: BookmarkerWeb
import Plug.Conn
import BookmarkerWeb.Gettext
alias BookmarkerWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/bookmarker_web/templates",
namespace: BookmarkerWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {BookmarkerWeb.LayoutView, "live.html"}
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def component do
quote do
use Phoenix.Component
unquote(view_helpers())
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
import BookmarkerWeb.Gettext
end
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import BookmarkerWeb.ErrorHelpers
import BookmarkerWeb.Gettext
alias BookmarkerWeb.Router.Helpers, as: Routes
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,143 @@
defmodule BookmarkerWeb.APIController do
use BookmarkerWeb, :controller
alias Bookmarker.{Repo, Bookmark}
import Ecto.Query
@doc """
A quicker way to return a JSON object with the key "error" and the given
message
"""
defp error(conn, message) do
json(conn, %{"error" => message})
end
@doc """
Takes a Bookmark struct and removes all the bits that aren't easily turned
into JSON
"""
defp sanitized_bookmark(bookmark) do
%{
id: bookmark.id,
creator: bookmark.creator,
category: bookmark.category,
remote_id: bookmark.remote_id,
notes: bookmark.notes
}
end
@doc """
POST /api/v1/bookmark - Create a new bookmark. This just builds a
%Bookmark{} and then inserts it into the database, returning the ID of the
newly created resource.
"""
def create(conn, _params) do
record = %Bookmark{
creator: conn.assigns[:user_id],
category: conn.body_params["category"],
remote_id: conn.body_params["remote_id"],
notes: conn.body_params["notes"]
}
{:ok, result} = Repo.insert(record)
# Return the new bookmark's ID
json(conn, %{
id: result.id
})
end
@doc """
GET /api/v1/bookmark - Returns a list of bookmarks created by the user.
Repo.all returns a list of, in this case, %Bookmark{} results, so each
of these must be sanitized with Enum.map/2 and our private function for
sanitizing the structs.
"""
def index(conn, _params) do
records = Repo.all(from b in Bookmark,
where: b.creator == ^conn.assigns[:user_id],
select: b)
|> Enum.map(fn b -> sanitized_bookmark(b) end)
json(conn, records)
end
@doc """
GET /api/v1/bookmark/single/:id - Returns a single bookmark by ID. If
Repo.get/2 returns `nil` here, then the bookmark can't be found and we
return 404. Otherwise, we sanitize the struct and return it as a JSON
string.
If the bookmark doesn't belong to the user, then the server returns 403.
"""
def show(conn, %{"id" => id}) do
case Repo.get(Bookmark, id) do
nil -> conn
|> put_status(404)
|> error("Bookmark not found")
bookmark ->
if bookmark.creator == conn.assigns[:user_id] do
conn
|> json(sanitized_bookmark(bookmark))
else
conn
|> put_status(403)
|> error("You do not have access to this bookmark")
end
end
end
@doc """
DELETE /api/v1/bookmark/:id - Gets the bookmark and then deletes it from
the database. If the bookmark doesn't belong to the user trying to delete
it, then the server returns 403.
This assumes that there won't be any malformed requests, which is not
ideal, especially if we're not the only ones developing clients
"""
def delete(conn, %{"id" => id}) do
case Repo.get(Bookmark, id) do
nil ->
conn
|> put_status(404)
|> error("Bookmark not found")
bookmark ->
if bookmark.creator == conn.assigns[:user_id] do
Repo.delete!(bookmark)
json(conn, %{"id" => bookmark.id})
else
conn
|> put_status(403)
|> error("Cannot delete others' bookmarks")
end
end
end
@doc """
GET /api/v1/bookmark/category/:category - Get all of the given category
"""
def index_category(conn, %{"category" => category}) do
user_id = conn.assigns[:user_id]
records = Repo.all(from b in Bookmark,
where: b.creator == ^user_id and b.category == ^category,
select: b)
|> Enum.map(fn b -> sanitized_bookmark(b) end)
json(conn, records)
end
@doc """
GET /api/v1/bookmark/categories - Returns a list of all categories used in
bookmarks by a user. To do this, we fetch all records, select only their
categories and then deduplicate the list.
"""
def index_categories(conn, _params) do
user_id = conn.assigns.user_id
categories = Repo.all(from b in Bookmark,
where: b.creator == ^user_id,
select: b.category)
|> Enum.uniq()
json(conn, categories)
end
end

View File

@ -0,0 +1,19 @@
defmodule BookmarkerWeb.AppController do
use BookmarkerWeb, :controller
@doc """
GET / - Renders the root component
"""
def index(conn, _params) do
render(conn, "index.html")
end
@doc """
GET /u/:id - Sends the user back to "/" with the query param "u=:id". This
way, the user ID can be properly processed by our React app and the user
can be redirected within the single-page app to where they need to be.
"""
def send_home(conn, %{"id" => id}) do
redirect(conn, to: "/?u=" <> id)
end
end

View File

@ -0,0 +1,50 @@
defmodule BookmarkerWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :bookmarker
# 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.
@session_options [
store: :cookie,
key: "_bookmarker_key",
signing_salt: "QRe+pfSV"
]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :bookmarker,
gzip: false,
only: ~w(assets fonts images 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
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :bookmarker
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug BookmarkerWeb.Router
end

View File

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

View File

@ -0,0 +1,158 @@
defmodule BookmarkerWeb.Router do
use BookmarkerWeb, :router
@doc """
A quicker way to return a JSON object with the key "error" and the given
message
"""
defp error(conn, message) do
json(conn, %{"error" => message})
end
@doc """
Checks to see if the request includes an Authorization header. If it does,
then the value of the header is put into a more convenient place in the
`conn` object. Otherwise, the server returns a client error.
"""
defp has_authorization(conn, _params) do
# Fail if the user hasn't included the authorization header
case get_req_header(conn, "authorization") do
[auth] -> assign(conn, :auth, auth)
_ -> conn
|> put_status(401)
|> error("Unauthorized")
end
end
@doc """
Checks that the Authorization style is basic. More generally, ensures that
the header isn't malformatted. If it is, then the server complains that the
client is unauthorized. If not, then the user ID gets extracted from the
header and passed down the line.
"""
defp authorization_is_basic(conn, _params) do
case String.split(conn.assigns[:auth]) do
["Basic", id] -> assign(conn, :user_id, id)
# In this case, the header value doesn't fit the "Basic 123..."
# pattern and the server returns 401
_ -> conn
|> put_status(401)
|> error("Authorization must be Basic")
end
end
@doc """
Attempts to parse the user ID as an integer. If it can't do that, then
the Authorization header's value is malformed and a 401 error code is
returned. Otherwise, the :user_id key gets reassigned to the parsed integer
for later use
"""
defp credential_is_integer(conn, _params) do
case Integer.parse(conn.assigns.user_id) do
{id, _} ->
# Replace the :user_id with the parsed integer
conn
|> assign(:user_id, id)
:error -> conn
|> put_status(401)
|> error("Malformed Authorization credentials")
end
end
# Pipelines are a series of plugs. Plugs make changes to the connection
# before passing them on.
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {BookmarkerWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
# This pipeline, used for the API, ensures that the request has an
# Authorization header, ensures that the Authorization style is "Basic,"
# and then ensures that the credentials are just an integer. It then parses
# the request body as JSON so that it can be easily accessed later from the
# controllers.
pipeline :api do
plug :accepts, ["json"]
plug :has_authorization
plug :authorization_is_basic
plug :credential_is_integer
plug Plug.Parsers,
parsers: [:json],
json_decoder: {Jason, :decode!, [[floats: :decimals]]}
end
# Generates a semi-private page for the user to navigate to.
scope "/", BookmarkerWeb do
pipe_through :browser
get "/", AppController, :index
# On the front end, this is a single-page app. So, if you were to,
# say, bookmark your ID in the browser and navigate to it, Phoenix
# would get confused. This controller will pass the given ID to
# the home route as a query parameter so that the app can then in
# turn send the user where they need to be.
get "/u/:id", AppController, :send_home
end
scope "/api/v1", BookmarkerWeb do
pipe_through :api
# CORE API ENDPOINTS
# Create a new bookmark
post "/bookmark", APIController, :create
# Get all your bookmarks
get "/bookmark", APIController, :index
# Get a single bookmark by ID
get "/bookmark/single/:id", APIController, :show
# OTHER USEFUL ENDPOINTS
# Delete a bookmark
delete "/bookmark/:id", APIController, :delete
# Return all posts of a category
get "/bookmark/category/:category", APIController, :index_category
# Return a list of all categories
get "/bookmark/categories", APIController, :index_categories
end
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: BookmarkerWeb.Telemetry
end
end
# Enables the Swoosh mailbox preview in development.
#
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

View File

@ -0,0 +1,71 @@
defmodule BookmarkerWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
# Database Metrics
summary("bookmarker.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("bookmarker.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("bookmarker.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("bookmarker.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("bookmarker.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {BookmarkerWeb, :count_users, []}
]
end
end

View File

@ -0,0 +1,2 @@
<!-- The root of the root React component -->
<div id="root"></div>

View File

@ -0,0 +1,5 @@
<main class="container">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= @inner_content %>
</main>

View File

@ -0,0 +1,11 @@
<main class="container">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= live_flash(@flash, :info) %></p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<%= @inner_content %>
</main>

View File

@ -0,0 +1,15 @@
<!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.0"/>
<meta name="csrf-token" content={csrf_token_value()}>
<%= live_title_tag assigns[:page_title] || "Bookmarker", suffix: " · Phoenix Framework" %>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
</head>
<body>
<%= @inner_content %>
</body>
</html>

View File

@ -0,0 +1,3 @@
defmodule BookmarkerWeb.AppView do
use BookmarkerWeb, :view
end

View File

@ -0,0 +1,47 @@
defmodule BookmarkerWeb.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: "invalid-feedback",
phx_feedback_for: input_name(form, field)
)
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(BookmarkerWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(BookmarkerWeb.Gettext, "errors", msg, opts)
end
end
end

View File

@ -0,0 +1,16 @@
defmodule BookmarkerWeb.ErrorView do
use BookmarkerWeb, :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,7 @@
defmodule BookmarkerWeb.LayoutView do
use BookmarkerWeb, :view
# Phoenix LiveDashboard is available only in development by default,
# so we instruct Elixir to not warn if the dashboard route is missing.
@compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}}
end

70
bookmarker/mix.exs Normal file
View File

@ -0,0 +1,70 @@
defmodule Bookmarker.MixProject do
use Mix.Project
def project do
[
app: :bookmarker,
version: "0.1.0",
elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [: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: {Bookmarker.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.6.10"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 3.0"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 0.17.5"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.6"},
{:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
{:swoosh, "~> 1.3"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.18"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.deploy": ["esbuild default --minify", "phx.digest"]
]
end
end

35
bookmarker/mix.lock Normal file
View File

@ -0,0 +1,35 @@
%{
"castore": {:hex, :castore, "0.1.17", "ba672681de4e51ed8ec1f74ed624d104c0db72742ea1a5e74edbc770c815182f", [:mix], [], "hexpm", "d9844227ed52d26e7519224525cb6868650c272d4a3d327ce3ca5570c12163f9"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"ecto": {:hex, :ecto, "3.8.3", "5e681d35bc2cbb46dcca1e2675837c7d666316e5ada14eca6c9c609b6232817c", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af92dd7815967bcaea0daaaccf31c3b23165432b1c7a475d84144efbc703d105"},
"ecto_sql": {:hex, :ecto_sql, "3.8.2", "d7d44bc8d45ba9c85485952710c80408632a7336eb811b045e791718d11ddb5b", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b9b03d64360d6cc05dc263500a43c11740b5fd4552244c27efad358e98c75b3"},
"esbuild": {:hex, :esbuild, "0.5.0", "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"floki": {:hex, :floki, "0.32.1", "dfe3b8db3b793939c264e6f785bca01753d17318d144bd44b407fb3493acaa87", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "d4b91c713e4a784a3f7b1e3cc016eefc619f6b1c3898464222867cafd3c681a3"},
"gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
"phoenix": {:hex, :phoenix, "1.6.10", "7a9e8348c5c62e7fd2f74a1884b88d98251f87186a430048bfbdbab3e3f46736", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08cf70d42f61dd0ea381805bac3cddef57b7b92ade5acc6f6036aa25ecaca9a2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.17.10", "a8b61b1a825dc1f9c0808583c2a7e8d18dfbe22edb6d7271744a2013eca8adb1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3e77cf0fc9909e9e87fa48f82fa3c4e6f471b19edf481b1b1b31e7d47810f235"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"},
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"swoosh": {:hex, :swoosh, "1.7.1", "53bb5b8efaf5577bbad1480fb057d8bcb7b16097aa015ab9f369a01592c961ec", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8206d7da5038f0e11292b775b7e588fcd5dc74ac4dd8b661fe72f58207234747"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
}

View File

@ -0,0 +1,112 @@
## `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 have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(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 least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(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] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(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,95 @@
## This 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 has 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,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View File

@ -0,0 +1,14 @@
defmodule Bookmarker.Repo.Migrations.CreateBookmarks do
use Ecto.Migration
def change do
create table(:bookmarks) do
add :creator, :integer
add :category, :string
add :remote_id, :string
add :notes, :string
timestamps()
end
end
end

View File

@ -0,0 +1,11 @@
# 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:
#
# Bookmarker.Repo.insert!(%Bookmarker.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,5 @@
# See https://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: /

View File

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

View File

@ -0,0 +1,14 @@
defmodule BookmarkerWeb.ErrorViewTest do
use BookmarkerWeb.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(BookmarkerWeb.ErrorView, "404.html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(BookmarkerWeb.ErrorView, "500.html", []) == "Internal Server Error"
end
end

View File

@ -0,0 +1,8 @@
defmodule BookmarkerWeb.LayoutViewTest do
use BookmarkerWeb.ConnCase, async: true
# When testing helpers, you may want to import Phoenix.HTML and
# use functions such as safe_to_string() to convert the helper
# result into an HTML string.
# import Phoenix.HTML
end

View File

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

View File

@ -0,0 +1,38 @@
defmodule BookmarkerWeb.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 data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use BookmarkerWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import BookmarkerWeb.ConnCase
alias BookmarkerWeb.Router.Helpers, as: Routes
# The default endpoint for testing
@endpoint BookmarkerWeb.Endpoint
end
end
setup tags do
Bookmarker.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

View File

@ -0,0 +1,58 @@
defmodule Bookmarker.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,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use Bookmarker.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias Bookmarker.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Bookmarker.DataCase
end
end
setup tags do
Bookmarker.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Bookmarker.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into 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} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

View File

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