Commit fc39bbdb authored by Alex Castaño's avatar Alex Castaño

Merge branch 'release/0.0.3'

parents 7fd52212 f3d144bf
......@@ -24,7 +24,7 @@ config :moodle_net, MoodleNet.Repo,
# Reduce hash rounds for testing
config :pbkdf2_elixir, rounds: 1
config :moodle_net, :httpoison, HTTPoisonMock
config :moodle_net, :httpoison, HTTPoison
config :phoenix_integration,
endpoint: MoodleNetWeb.Endpoint
defmodule ActivityPub do
defdelegate new(params), to: ActivityPub.Builder
defdelegate insert(params), to: ActivityPub.SQLEntity
defdelegate update(entity, changes), to: ActivityPub.SQLEntity
defdelegate get_by_local_id(params), to: ActivityPub.SQLEntity
defdelegate get_by_id(params), to: ActivityPub.SQLEntity
defdelegate reload(params), to: ActivityPub.SQL.Query
......
defmodule ActivityPub.ApplyAction do
import ActivityPub.Guards
alias ActivityPub.SQL.{CollectionStatement, Query}
alias ActivityPub.SQL.{Alter}
alias ActivityPub.SQLEntity
def apply(entity) when not has_type(entity, "Activity"),
......@@ -23,22 +23,36 @@ defmodule ActivityPub.ApplyAction do
defp side_effect(follow) when has_type(follow, "Follow") do
# FIXME verify type of actors and objects
following_collections =
follow.actor
|> Query.preload_assoc(:following)
|> Enum.map(& &1.following)
Alter.add(follow.actor, :following, follow.object)
Alter.add(follow.object, :followers, follow.actor)
followers_collections =
follow.object
|> Query.preload_assoc(:followers)
|> Enum.map(& &1.followers)
:ok
end
defp side_effect(like) when has_type(like, "Like") do
# FIXME verify type of actors
Alter.add(like.actor, :liked, like.object)
Alter.add(like.object, :likers, like.actor)
:ok
end
CollectionStatement.add(following_collections, follow.object)
CollectionStatement.add(followers_collections, follow.actor)
defp side_effect(undo = %{object: [like]}) when has_type(undo, "Undo") and has_type(like, "Like") do
Alter.remove(like.actor, :liked, like.object)
Alter.remove(like.object, :likers, like.actor)
:ok
end
defp side_effect(undo = %{object: [follow]}) when has_type(undo, "Undo") and has_type(follow, "Follow") do
Alter.remove(follow.actor, :following, follow.object)
Alter.remove(follow.object, :followers, follow.actor)
:ok
end
defp side_effect(_), do: :ok
# TODO
defp insert_into_inbox(_activity) do
:ok
......
defmodule ActivityPub.Aspect do
alias ActivityPub.{Association}
alias ActivityPub.{Field, Association}
defmacro __using__(options) do
quote bind_quoted: [options: options] do
......@@ -108,7 +108,14 @@ defmodule ActivityPub.Aspect do
end
defp define_field(mod, name, type, opts) do
Module.put_attribute(mod, :aspect_fields, {name, type})
opts =
opts
|> Keyword.put(:aspect, mod)
|> Keyword.put(:name, name)
|> Keyword.put(:type, type)
field = Field.build(opts)
Module.put_attribute(mod, :aspect_fields, {name, field})
put_struct_field(mod, name, Keyword.get(opts, :default))
end
......@@ -124,8 +131,13 @@ defmodule ActivityPub.Aspect do
def __aspect__(fields, assocs) do
types_quoted =
for {name, type} <- fields do
{[:type, name], Macro.escape(type)}
for {name, field} <- fields do
{[:type, name], Macro.escape(field.type)}
end
field_quoted =
for {name, field} <- fields do
{[:field, name], Macro.escape(field)}
end
assoc_quoted =
......@@ -133,7 +145,7 @@ defmodule ActivityPub.Aspect do
{[:association, name], Macro.escape(assoc)}
end
[types_quoted, assoc_quoted]
[types_quoted, field_quoted, assoc_quoted]
end
defmacro assoc(name, opts \\ []) do
......
defmodule ActivityPub.ObjectAspect do
use ActivityPub.Aspect, persistence: ActivityPub.SQLObjectAspect
alias ActivityPub.{LanguageValueType, StringListType}
alias ActivityPub.{LanguageValueType}
aspect do
assoc(:attachment)
......@@ -29,12 +29,17 @@ defmodule ActivityPub.ObjectAspect do
field(:updated, :utc_datetime)
# FIXME url is a relation
# field(:url, EntityType, default: [])
field(:url, StringListType, default: [])
field(:to, StringListType, default: [])
field(:bto, StringListType, default: [])
field(:cc, StringListType, default: [])
field(:bcc, StringListType, default: [])
field(:url, :string, functional: false)
field(:to, :string, functional: false)
field(:bto, :string, functional: false)
field(:cc, :string, functional: false)
field(:bcc, :string, functional: false)
field(:media_type, :string)
field(:duration, :string)
# FIXME this doesn't exist in ActivityPub
# adding because it is easier
assoc(:likers)
field(:likers_count, :integer, autogenerated: true)
end
end
defmodule ActivityPub.ResourceAspect do
use ActivityPub.Aspect, persistence: ActivityPub.SQLResourceAspect
aspect do
field(:same_as, :string)
field(:in_language, :string, functional: false)
field(:public_access, :boolean, default: true)
field(:is_accesible_for_free, :boolean, default: true)
field(:license, :string)
field(:learning_resource_type, :string)
field(:educational_use, :string, functional: false)
field(:time_required, :integer)
field(:typical_age_range, :string)
end
end
......@@ -2,19 +2,55 @@ defmodule ActivityPub.Builder do
@moduledoc false
alias ActivityPub.{Entity, Context, Types, Metadata}
alias ActivityPub.BuildError
alias ActivityPub.{BuildError, LanguageValueType}
require ActivityPub.Guards, as: APG
def new(params \\ %{})
def new(params) when is_list(params), do: params |> Enum.into(%{}) |> new()
def new(params) when is_map(params),
def new(params) when is_map(params) or is_list(params),
do: build(:new, params, nil, nil)
def parse(_params) do
def update(entity, changes)
when APG.is_entity(entity) and (is_map(changes) or is_list(changes)) do
changes = normalize_keys(changes)
# TODO update the context?
with {:ok, changes} <- update_id(entity, changes),
{:ok, changes} <- update_type(entity, changes),
{:ok, entity, changes} <- merge_aspects_fields(entity, changes),
:ok <- verify_not_assoc_updates(entity, changes),
entity = update_extension_fields(entity, changes) do
{:ok, entity}
end
end
defp update_id(%{id: id}, %{"id" => id} = changes) do
{:ok, Map.delete(changes, "id")}
end
defp update_id(_, %{"id" => id}) do
{:error, %BuildError{path: [:id], value: id, message: "cannot be changed"}}
end
defp update_id(_, changes) do
{:ok, changes}
end
defp update_type(%{type: types}, %{"type" => raw_types} = changes) do
with {:ok, change_types} <- Types.build(raw_types) do
if MapSet.equal?(MapSet.new(types), MapSet.new(change_types)) do
{:ok, Map.delete(changes, "type")}
else
{:error, %BuildError{path: [:type], value: raw_types, message: "cannot be changed"}}
end
end
end
defp update_type(_, changes), do: {:ok, changes}
def load(_params) do
end
......@@ -41,7 +77,7 @@ defmodule ActivityPub.Builder do
{raw_type, params} = Map.pop(params, "type")
with {:ok, context} <- context(:new, raw_context, parent),
{:ok, type} <- type(:new, raw_type),
{:ok, type} <- Types.build(raw_type),
meta = Metadata.new(type),
entity = %{__ap__: meta, id: nil, type: type, "@context": context},
{:ok, entity, params} <- merge_aspects_fields(entity, params),
......@@ -58,58 +94,132 @@ defmodule ActivityPub.Builder do
defp merge_aspects_fields(entity, params) do
entity
|> Entity.aspects()
|> Enum.reduce({:ok, entity, params}, &cast_fields(&2, &1))
|> Enum.reduce_while({:ok, entity, params}, fn aspect, {:ok, entity, params} ->
case merge_aspect_fields(aspect, entity, params) do
{:ok, _, _} = ret -> {:cont, ret}
{:error, _} = error -> {:halt, error}
end
end)
end
defp merge_aspect_fields(aspect, entity, params) do
aspect.__aspect__(:fields)
|> Enum.reduce_while({:ok, entity, params}, fn field_name, {:ok, entity, params} ->
field_def = aspect.__aspect__(:field, field_name)
case put_field(entity, params, field_def) do
{:ok, _, _} = ret -> {:cont, ret}
{:error, _} = error -> {:halt, error}
end
end)
end
defp put_field(entity, params, field_def) do
case get_raw_value(params, field_def) do
:not_found ->
entity = Map.put_new(entity, field_def.name, field_def.default)
{:ok, entity, params}
{:ok, raw_value, params} ->
with {:ok, entity} <- cast_and_put(entity, raw_value, field_def) do
{:ok, entity, params}
end
{:error, _} = error ->
error
end
end
defp get_raw_value(params, %{type: LanguageValueType, name: key}) do
{map_value, params} = Map.pop(params, "#{key}_map", :default)
{value, params} = Map.pop(params, to_string(key), :default)
case {value, map_value} do
{:default, :default} ->
:not_found
{value, :default} ->
{:ok, value, params}
{:default, value} ->
{:ok, value, params}
value ->
msg = "a language value cannot receive the string and map format at the same time"
error = %BuildError{path: [key], value: value, message: msg}
{:error, error}
end
end
defp cast_fields({:ok, entity, params}, aspect) do
Enum.reduce(
aspect.__aspect__(:fields),
{:ok, entity, params},
&cast_field(&2, aspect.__aspect__(:type, &1), &1)
)
defp get_raw_value(params, field_def) do
case Map.pop(params, to_string(field_def.name), :default) do
{:default, _} ->
:not_found
{raw_value, params} ->
{:ok, raw_value, params}
end
end
defp cast_fields(ret, _aspect), do: ret
defp cast_and_put(_entity, raw_value, %{autogenerated: true} = field_def) do
msg = "is an autogenerated field, but data is received"
field_name = to_string(field_def.name)
error = %BuildError{path: [field_name], value: raw_value, message: msg}
{:error, error}
end
defp cast_field({:ok, entity, params}, ActivityPub.LanguageValueType, key) do
defp cast_and_put(entity, raw_value, %{type: LanguageValueType, functional: true} = field_def) do
lang = entity[:"@context"].language
map_key = "#{key}_map"
param_key = to_string(key)
# FIXME if it has the two keys is an error
value = Map.get(params, map_key) || Map.get(params, param_key)
params = params |> Map.delete(param_key) |> Map.delete(map_key)
with {:ok, value} <- LanguageValueType.cast(raw_value, lang) do
{:ok, Map.put(entity, field_def.name, value)}
else
:error ->
field_name = to_string(field_def.name)
error = %BuildError{path: [field_name], value: raw_value, message: "is invalid"}
{:error, error}
end
end
with {:ok, value} <- ActivityPub.LanguageValueType.cast(value, lang) do
{:ok, Map.put(entity, key, value), params}
defp cast_and_put(entity, raw_value, %{type: LanguageValueType, functional: false} = field_def) do
lang = entity[:"@context"].language
with {:ok, value} <- language_value_list_cast(List.wrap(raw_value), lang) do
{:ok, Map.put(entity, field_def.name, value)}
else
:error ->
error = %BuildError{path: [param_key], value: value, message: "is invalid"}
field_name = to_string(field_def.name)
error = %BuildError{path: [field_name], value: raw_value, message: "is invalid"}
{:error, error}
end
end
defp cast_field({:ok, entity, params}, type, key) do
key_str = to_string(key)
{value, params} = Map.pop(params, key_str)
defp cast_and_put(entity, raw_value, field_def) do
wrapped_raw_value = if field_def.functional, do: raw_value, else: List.wrap(raw_value)
type = if field_def.functional, do: field_def.type, else: {:array, field_def.type}
case cast_value(type, value) do
case Ecto.Type.cast(type, wrapped_raw_value) do
{:ok, value} ->
{:ok, Map.put(entity, key, value), params}
{:ok, Map.put(entity, field_def.name, value)}
:error ->
error = %BuildError{path: [key_str], value: value, message: "is invalid"}
error = %BuildError{
path: [to_string(field_def.name)],
value: raw_value,
message: "is invalid"
}
{:error, error}
end
end
defp cast_field(error, _, _), do: error
defp cast_value(type, value) do
# This is to avoid nil values not being cast
if Ecto.Type.primitive?(type),
do: Ecto.Type.cast(type, value),
else: type.cast(value)
defp language_value_list_cast(list, lang) do
Enum.reduce_while(list, [], fn raw_value, acc ->
case LanguageValueType.cast(raw_value, lang) do
{:ok, value} -> {:cont, [value | acc]}
:error -> :error
end
end)
end
defp merge_aspects_assocs(entity, params) do
......@@ -214,7 +324,36 @@ defmodule ActivityPub.Builder do
defp context(:new, nil, parent), do: {:ok, parent[:"@context"]}
defp context(:new, raw_context, _parent), do: Context.build(raw_context)
defp type(:new, raw_type), do: Types.build(raw_type)
defp verify_not_assoc_updates(entity, params) do
entity
|> Entity.aspects()
|> Enum.reduce_while(:ok, fn aspect, :ok ->
aspect.__aspect__(:associations)
|> Enum.reduce_while(:ok, fn association_name, :ok ->
association_name_str = to_string(association_name)
if Map.has_key?(params, association_name_str) do
value = params[association_name]
msg = "association cannot be updated"
error = %BuildError{path: [association_name], value: value, message: msg}
{:halt, {:error, error}}
else
{:cont, :ok}
end
end)
|> case do
:ok -> {:cont, :ok}
{:error, _} = error -> {:halt, error}
end
end)
end
defp update_extension_fields(entity, extension_fields) do
Enum.reduce(extension_fields, entity, fn
{key, nil}, entity -> Map.delete(entity, key)
{key, value}, entity -> Map.put(entity, key, value)
end)
end
defp build_error(key, value, message, parent_key \\ nil) do
e =
......@@ -229,7 +368,7 @@ defmodule ActivityPub.Builder do
defp insert_parent_keys(%BuildError{} = e, parent_key),
do: %{e | path: [parent_key | e.path]}
defp normalize_keys(%{} = params) do
def normalize_keys(params) do
params
|> Enum.map(fn {key, value} -> {to_string(key), value} end)
|> Enum.into(%{}, fn
......
defmodule ActivityPub.SQL.Common do
alias ActivityPub.Entity
import ActivityPub.Guards
def local_id(%ActivityPub.SQL.AssociationNotLoaded{local_id: local_id})
when not is_nil(local_id),
do: local_id
def local_id(entity) when is_entity(entity) and has_status(entity, :loaded),
do: Entity.local_id(entity)
def local_id(entity) when is_entity(entity) and not has_status(entity, :loaded),
do: raise ArgumentError, "Entity must be loaded to persist correctly"
def local_id(id) when is_integer(id), do: id
end
defmodule ActivityPub.StringListType do
#FIXME this module probably is not needed anymore
@behaviour Ecto.Type
def type, do: {:array, :string}
......
defmodule ActivityPub.Field do
@enforce_keys [:aspect, :name, :type]
defstruct aspect: nil,
name: nil,
functional: true,
type: nil,
default: nil,
autogenerated: false
def build(opts) do
opts = add_default_value(opts)
struct!(__MODULE__, opts)
end
defp add_default_value(keywords) do
cond do
Keyword.has_key?(keywords, :default) -> keywords
keywords[:functional] == false -> Keyword.put(keywords, :default, [])
true -> Keyword.put(keywords, :default, nil)
end
end
end
defmodule ActivityPub.SQL.Alter do
alias MoodleNet.Repo
alias ActivityPub.SQL.Associations.{BelongsTo, ManyToMany, Collection}
import ActivityPub.SQL.Common
alias ActivityPub.SQL.Query
# FIXME Use multi always
# def add(Ecto.Multi{} = multi, prefix, subject, relation, target)
def add(subject, relation, target) when not is_list(subject),
do: add([subject], relation, target)
def add(subject, relation, target) when not is_list(target),
do: add(subject, relation, [target])
def remove(subject, relation, target) when not is_list(subject),
do: remove([subject], relation, target)
def remove(subject, relation, target) when not is_list(target),
do: remove(subject, relation, [target])
for sql_aspect <- ActivityPub.SQLAspect.all() do
Enum.map(sql_aspect.__sql_aspect__(:associations), fn
# FIXME this can be refactored
%ManyToMany{
name: name,
sql_aspect: _sql_aspect,
table_name: table_name,
type: _type,
join_keys: [subject_key, target_key]
} ->
def add(subjects, unquote(name), targets) do
# FIXME
# verify_aspect!(subjects, unquote(_sql_aspect))
# verify_aspect!(targets, unquote(_type))
subject_ids = Enum.map(subjects, &local_id/1)
target_ids = Enum.map(targets, &local_id/1)
insert_all(
unquote_splicing([table_name, subject_key, target_key]),
subject_ids,
target_ids
)
end
def remove(subjects, unquote(name), targets) do
# FIXME
# verify_aspect!(subjects, unquote(_sql_aspect))
# verify_aspect!(targets, unquote(_type))
subject_ids = Enum.map(subjects, &local_id/1)
target_ids = Enum.map(targets, &local_id/1)
delete_all(
unquote_splicing([table_name, subject_key, target_key]),
subject_ids,
target_ids
)
end
%Collection{
name: name,
sql_aspect: sql_aspect,
table_name: table_name,
type: _type,
join_keys: [subject_key, target_key]
} ->
def add(subjects, unquote(name), targets) do
# FIXME
# verify_aspect!(subjects, unquote(_sql_aspect))
# verify_aspect!(targets, unquote(_type))
subjects = Query.preload_aspect(subjects, unquote(sql_aspect))
subject_ids = Enum.map(subjects, &local_id(&1[unquote(name)]))
target_ids = Enum.map(targets, &local_id/1)
insert_all(
unquote_splicing([table_name, subject_key, target_key]),
subject_ids,
target_ids
)
end
def remove(subjects, unquote(name), targets) do
# FIXME
# verify_aspect!(subjects, unquote(_sql_aspect))
# verify_aspect!(targets, unquote(_type))
subjects = Query.preload_aspect(subjects, unquote(sql_aspect))
subject_ids = Enum.map(subjects, &local_id(&1[unquote(name)]))
target_ids = Enum.map(targets, &local_id/1)
delete_all(
unquote_splicing([table_name, subject_key, target_key]),
subject_ids,
target_ids
)
end
%BelongsTo{name: name} ->
def add(_, unquote(name), _) do
raise Argument, "Cannot add items to a BelongsTo relation"
end
def remove(_, unquote(name), _) do
raise Argument, "Cannot add items to a BelongsTo relation"
end
end)
end
def add(_, relation, _) do
raise ArgumentError, "not valid relation #{inspect(relation)}"
end
def remove(_, relation, _) do
raise ArgumentError, "not valid relation #{inspect(relation)}"
end
defp insert_all(table_name, subject_key, target_key, subject_ids, target_ids) do
data =
for s_id <- subject_ids,
t_id <- target_ids,
do: %{subject_key => s_id, target_key => t_id}
opts = [on_conflict: :nothing]
{num, nil} = Repo.insert_all(table_name, data, opts)
{:ok, num}
end
defp delete_all(table_name, subject_key, target_key, subject_ids, target_ids) do
{number, nil} =
delete_all_query(
table_name,
subject_key,
target_key,
subject_ids,
target_ids
)
|> Repo.delete_all()
{:ok, number}
end
defp delete_all_query(table_name, subject_key, target_key, subject_ids, target_ids) do
import Ecto.Query, only: [from: 2]
from(rel in table_name,
where: field(rel, ^subject_key) in ^subject_ids and field(rel, ^target_key) in ^target_ids
)
end
end
defmodule ActivityPub.SQL.AssociationNotLoaded do
defstruct []
@enforce_keys [:sql_assoc, :sql_aspect]
defstruct sql_assoc: nil, sql_aspect: nil, local_id: nil
end
defmodule ActivityPub.SQLAssociations.BelongsTo do
defmodule ActivityPub.SQL.Associations.BelongsTo do
@enforce_keys [:sql_aspect, :aspect, :name]
defstruct sql_aspect: nil,
aspect: nil,
......
defmodule ActivityPub.SQL.Associations.Collection do
@enforce_keys [:sql_aspect, :aspect, :name]
defstruct sql_aspect: nil,
aspect: nil,
name: nil,
# FIXME this type could be much more useful if it is the final item
type: "Collection",
autogenerated: true,
table_name: "activity_pub_collection_items",
join_keys: [:subject_id, :target_id],
foreign_key: nil
end
# FIXME I don't like this name
defmodule ActivityPub.SQL.CollectionStatement do
import ActivityPub.Guards
alias MoodleNet.Repo
import ActivityPub.Entity, only: [local_id: 1]
def add(collection, _) when not has_status(collection, :loaded),