Merge branch 'master' into brand

This commit is contained in:
Beatrice Olivera 2018-08-31 10:33:38 +01:00 committed by GitHub
commit 4fdc56ca5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 455 additions and 24 deletions

View File

@ -17,6 +17,7 @@ let localstream;
window.onload = () => { window.onload = () => {
currentUser = document.getElementById("current-user").innerHTML; currentUser = document.getElementById("current-user").innerHTML;
console.log(currentUser)
localVideo = document.getElementById("local-video"); localVideo = document.getElementById("local-video");
remoteVideoContainer = document.getElementById("remote-video-container"); remoteVideoContainer = document.getElementById("remote-video-container");
}; };
@ -40,17 +41,49 @@ document.onreadystatechange = async () => {
} }
}; };
// find chatroom
// const handleJoinSession = async () => {
// App.session = await App.cable.subscriptions.create("VideoSessionChannel", {
// connected: () => {
// broadcastData({
// type: JOIN_ROOM,
// from: currentUser
// });
// },
// received: data => {
// console.log("received", data);
// if (data.from === currentUser) return;
// switch (data.type) {
// case JOIN_ROOM:
// return joinRoom(data);
// case EXCHANGE:
// if (data.to !== currentUser) return;
// return exchange(data);
// case REMOVE_USER:
// return removeUser(data);
// default:
// return;
// }
// }
// });
// };
const chatroomId = document.getElementById('chatroom-hook').dataset["chatroomId"]
const handleJoinSession = async () => { const handleJoinSession = async () => {
App.session = await App.cable.subscriptions.create("VideoSessionChannel", { App['chatroom' + chatroomId] = await App.cable.subscriptions.create({
channel: "ChatRoomsChannel",
room: chatroomId
}, {
connected: () => { connected: () => {
console.log(chatroomId)
broadcastData({ broadcastData({
type: JOIN_ROOM, type: JOIN_ROOM,
from: currentUser from: currentUser,
room: chatroomId
}); });
}, },
received: data => { received: data => {
console.log("received", data); console.log(data)
if (data.from === currentUser) return; if (data.from === currentUser) return;
switch (data.type) { switch (data.type) {
case JOIN_ROOM: case JOIN_ROOM:
@ -88,7 +121,6 @@ const joinRoom = data => {
}; };
const removeUser = data => { const removeUser = data => {
console.log("removing user", data.from);
let video = document.getElementById(`remoteVideoContainer+${data.from}`); let video = document.getElementById(`remoteVideoContainer+${data.from}`);
video && video.remove(); video && video.remove();
delete pcPeers[data.from]; delete pcPeers[data.from];
@ -96,18 +128,21 @@ const removeUser = data => {
const broadcastData = data => { const broadcastData = data => {
fetch("sessions", { if (data.type === EXCHANGE) {
console.log("yayyy")
}
fetch("chat_room_sessions", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
headers: { "content-type": "application/json" } headers: { "content-type": "application/json", "X-CSRF-Token": document.querySelector('meta[name=csrf-token]').content }
}); });
}; };
const createPC = (userId, isOffer) => { const createPC = (userId, isOffer) => {
let pc = new RTCPeerConnection(ice); let pc = new RTCPeerConnection(ice);
let test = userId
pcPeers[userId] = pc; pcPeers[userId] = pc;
pc.addStream(localstream); pc.addStream(localstream);
if (isOffer) { if (isOffer) {
pc pc
.createOffer() .createOffer()
@ -117,7 +152,8 @@ const createPC = (userId, isOffer) => {
type: EXCHANGE, type: EXCHANGE,
from: currentUser, from: currentUser,
to: userId, to: userId,
sdp: JSON.stringify(pc.localDescription) sdp: JSON.stringify(pc.localDescription),
room: chatroomId
}); });
}) })
.catch(logError); .catch(logError);
@ -129,7 +165,8 @@ const createPC = (userId, isOffer) => {
type: EXCHANGE, type: EXCHANGE,
from: currentUser, from: currentUser,
to: userId, to: userId,
candidate: JSON.stringify(event.candidate) candidate: JSON.stringify(event.candidate),
room: chatroomId
}); });
} }
}; };
@ -186,7 +223,8 @@ const exchange = data => {
type: EXCHANGE, type: EXCHANGE,
from: currentUser, from: currentUser,
to: data.from, to: data.from,
sdp: JSON.stringify(pc.localDescription) sdp: JSON.stringify(pc.localDescription),
room: chatroomId
}); });
}); });
} }

View File

@ -50,6 +50,10 @@ a:hover {
color: $icon; color: $icon;
font-size: 25px; font-size: 25px;
} }
button {
height: 0;
width: 0;
}
} }
} }

View File

@ -0,0 +1,9 @@
class ChatRoomsChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_room_#{params[:room]}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@ -0,0 +1,9 @@
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_from "notifications"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@ -0,0 +1,24 @@
class ChatRoomsController < ApplicationController
def show
@chat_room = ChatRoom.find(params[:id])
end
def create
# HTTP status code 200 with an empty body
head :no_content
puts ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>11123213213213123213"
puts params
puts ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>11123213213213123213"
ActionCable.server.broadcast "chat_room_#{params[:room]}", session_params
end
private
def session_params
# SDP = Session description protocol (codec info from client)
# Candidate = ICE candidates (e.g. TURN and STUN server)
params.permit(:type, :from, :to, :sdp, :candidate, :room)
end
end

View File

@ -1,5 +1,6 @@
class PagesController < ApplicationController class PagesController < ApplicationController
# skip_before_action :authenticate_user!, only: [:call] # skip_before_action :authenticate_user!, only: [:call]
skip_before_action :verify_authenticity_token
def call def call
end end
@ -9,4 +10,63 @@ class PagesController < ApplicationController
def home def home
end end
def cable_testing
chatroom = 'chat_room_' + params[:chat_room_id]
puts params
ActionCable.server.broadcast(chatroom, { message: 'test' })
head :ok
end
def establish_call
head :ok
puts "params: #{params}"
chat_room = ChatRoom.create!
puts "Created chat room with id: #{chat_room.id}"
chat_room_participation = ChatRoomParticipation.create!(chat_room: chat_room, user: current_user)
puts "Created chat room participation with user: #{current_user.email} assigned to chat_room #{chat_room.id}"
puts "Subscribed user to chat room"
contact = User.find(params[:contact_id])
request = Request.create!(chat_room: chat_room, user: contact)
puts "Made a request to call #{contact.email}"
ActionCable.server.broadcast('notifications', {
message: {
user_id: contact.id,
chat_room_id: chat_room.id
}
})
end
def accept_call
puts "-----------------------------------------"
puts params
puts "IT WORKED"
chat_room = ChatRoom.find(params[:chat_room_id])
request = Request.where("user_id = ? AND chat_room_id = ?", current_user.id, chat_room.id)
request[0].accepted = true
puts "create new chat room participation"
chat_room_participation = ChatRoomParticipation.create!(chat_room: chat_room, user: current_user)
puts "Created chat room participation with user: #{current_user.email} assigned to chat_room #{chat_room.id}"
other_caller = chat_room.users.find { |u| u != current_user } # remember to update this later
puts ">>>>>>>>>>>>>>>>>>>>>>>>>>>>..HHHHHHHHH"
puts other_caller
puts ">>>>>>>>>>>>>>>>>>>>>>>>>>>>..HHHHHHHHH"
# redirect caller to chat room
ActionCable.server.broadcast('notifications', {
head: 302, # redirection code, just to make it clear what you're doing
path: chat_room_path(chat_room), # you'll need to use url_helpers, so include them in your file
body: { caller: other_caller.id }
}
# other_caller, # or however you identify your subscriber
)
# redirect callee to chat room
redirect_to chat_room_path(chat_room)
# broadcast another message to caller
# head: 302
end
end end

View File

@ -0,0 +1,7 @@
class RequestsController < ApplicationController
def accept
# Create new Chat Room
end
end

View File

@ -0,0 +1,35 @@
import ActionCable from 'actioncable'
// create App object with key cable == new cosumer
(function() {
window.App || (window.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);
// find chatroom id
const chatroomId = document.getElementById('chatroom-hook').dataset["chatroomId"]
// create subsciptions
App['chatroom' + chatroomId] = App.cable.subscriptions.create({
channel: 'ChatRoomsChannel',
room: chatroomId
}, {
connected: () => {
},
received: data => {
}
})
// Testing ActionCable
const testBtn = document.getElementById('test-btn')
testBtn.addEventListener('click', event => {
fetch(`/chat_rooms/${chatroomId}/cable_testing` , {
method: 'POST',
body: JSON.stringify({})
})
})

View File

@ -0,0 +1,56 @@
import ActionCable from 'actioncable'
// create App object with key cable == new cosumer
(function() {
window.App || (window.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);
const userId = parseInt(document.getElementById("my-user-id").dataset["userId"])
let chatRoomId = null
App.cable.subscriptions.create({
channel: 'NotificationsChannel'
}, {
connected: () => {
console.log('Connected to NotificationsChannel')
},
received: data => {
// console.log(data["message"]["user_id"])
// console.log(userId)
console.log("received broadcast")
// console.log(data.body)
if (data.head === 302 && data.body["caller"] === userId && data.path ) {
window.location.pathname = data.path
} else if (data["message"]["user_id"] === userId) {
console.log("TRIGGER MODAL")
const acceptButton = document.getElementById('accept-button')
acceptButton.style.display = "block"
// const receiveCall = document.getElementById('receive-call')
// receiveCall.dataset.toggle = 'modal'
// receiveCall.dataset.target ='#calleeModal'
// console.log(receiveCall)
// const calleeModal = document.getElementById('calleeModal')
// calleeModal.modal("show")
chatRoomId = data["message"]["chat_room_id"]
console.log(`user with id: ${userId} needs to subscribe to chatroom ${[chatRoomId]}`)
} else {
console.log(data)
}
}
})
const acceptButton = document.getElementById('accept-button')
acceptButton.addEventListener('click', event => {
// event.preventDefault()
document.getElementById('chat-room-id').value = chatRoomId
})

5
app/models/chat_room.rb Normal file
View File

@ -0,0 +1,5 @@
class ChatRoom < ApplicationRecord
has_many :chat_room_participations
has_many :users, through: :chat_room_participations
has_many :requests
end

View File

@ -0,0 +1,4 @@
class ChatRoomParticipation < ApplicationRecord
belongs_to :user
belongs_to :chat_room
end

View File

@ -8,7 +8,7 @@ class Connection < ApplicationRecord
def create_inverted_connection def create_inverted_connection
unless Connection.where('user_id = ? and contact_id = ?', self.contact.id, self.user.id).length > 0 unless Connection.where('user_id = ? and contact_id = ?', self.contact.id, self.user.id).length > 0
Connection.create!(user: self.contact, contact: self.user) inverted = Connection.create!(user: self.contact, contact: self.user)
end end
end end
end end

4
app/models/request.rb Normal file
View File

@ -0,0 +1,4 @@
class Request < ApplicationRecord
belongs_to :user
belongs_to :chat_room
end

View File

@ -6,6 +6,9 @@ class User < ApplicationRecord
:recoverable, :rememberable, :validatable :recoverable, :rememberable, :validatable
has_many :connections has_many :connections
has_many :chat_room_participations
has_many :chat_rooms, through: :chat_room_participations
has_many :requests
mount_uploader :photo, PhotoUploader mount_uploader :photo, PhotoUploader

View File

@ -0,0 +1,27 @@
<button onclick="handleJoinSession()" data-room="<%= @chat_room.id %>">Join Room</button>
<!-- <div id="join-session" data-room="<%= @chat_room.id %>"> -->
<!-- <h1 style="color: white;">This is chatroom #<%= @chat_room.id %></h1> -->
<div id="chatroom-hook" data-chatroom-id='<%= @chat_room.id %>'></div>
<!-- <button id="test-btn">Test Connection</button> -->
<div class="call-container">
<div id="remote-video-container">
<div id="video_overlays">
<video id="local-video" autoplay></video>
</div>
</div>
</div>
<div>
<span id="current-user" class="text-color" style="display:none"><%= current_user.id %></span>
</div>
<!-- <script>
const joinSession = document.getElementById("join-session")
joinSession.addEventListener('load', event => {
handleJoinSession()
})
</script> -->
<%= javascript_pack_tag 'chatrooms' %>

View File

@ -1,18 +1,30 @@
<%
def hello_world
puts "Hello World"
end
%>
<div class="card"> <div class="card">
<div class="info"> <div class="info">
<img src="https://kitt.lewagon.com/placeholder/users/ssaunier" alt="" class="profile img-circle" width=65> <img src="https://kitt.lewagon.com/placeholder/users/ssaunier" alt="" class="profile img-circle" width=65>
<div class="text text-color"> <div class="text text-color">
<p>FirstName LastName</p> <% if contact.nil? || contact.nil? %> <!-- REPLACE WITH FIRST NAME AND LAST NAME -->
<p><%= contact.email %></p>
<% else %>
<p><%= contact.email %></p>
<!-- <p><%#= contact.first_name %> <%#= contact.last_name %></p> -->
<% end %>
<p class="darker">Last call: <%= Time.now %></p> <p class="darker">Last call: <%= Time.now %></p>
</div> </div>
</div> </div>
<div class="call"> <div class="call" data-user-id="<%= contact.id %>">
<%= link_to "#", 'data-toggle':"modal", 'data-target':"#myModal" do %> <%= link_to establish_call_path(contact.id), 'data-toggle':"modal", 'data-target':"#myModal" do %>
<i class="fas fa-phone"></i> <i class="fas fa-phone"></i>
<% end %> <% end %>
</div> </div>
</div> </div>
<!-- Modal --> <!-- Modal -->
<div class="modal fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"> <div class="modal fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="vertical-alignment-helper"> <div class="vertical-alignment-helper">
@ -27,7 +39,17 @@
<img src="https://kitt.lewagon.com/placeholder/users/ssaunier" alt="" class="profile img-circle" width=150> <img src="https://kitt.lewagon.com/placeholder/users/ssaunier" alt="" class="profile img-circle" width=150>
<div class="modal-text"> <div class="modal-text">
<h4 class="modal-title" id="myModalLabel">Calling</h4> <h4 class="modal-title" id="myModalLabel">Calling</h4>
<h4><strong>FirstName LastName</strong></h4> <h4>
<strong>
<!-- REPLACE WITH FIRST NAME AND LAST NAME -->
<% if contact.nil? || contact.nil? %>
<p><%= contact.email %></p>
<% else %>
<p><%= contact.email %></p>
<!-- <p><%#= contact.first_name %> <%#= contact.last_name %></p> -->
<% end %>
</strong>
</h4>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
<!--
<div class="call-container"> <div class="call-container">
<div id="remote-video-container"> <div id="remote-video-container">
<div id="video_overlays"> <div id="video_overlays">
@ -7,7 +7,7 @@
</div> </div>
</div> </div>
</div> </div>
-->

View File

@ -2,8 +2,8 @@
<div class="contacts-container text-color"> <div class="contacts-container text-color">
<div class="contacts half"> <div class="contacts half">
<h2>Contacts</h2> <h2>Contacts</h2>
<% 5.times do %> <% current_user.contacts.each do |contact| %>
<%= render "pages/contact" %> <%= render "pages/contact", contact: contact %>
<% end %> <% end %>
</div> </div>
<div class="video-feed half"> <div class="video-feed half">
@ -14,6 +14,13 @@
</div> </div>
</div> </div>
<div id="my-user-id" data-user-id="<%= current_user.id %>"></div>
<form action="/accept_call" method="post">
<input type="hidden" id="chat-room-id" name="chat_room_id" value=""/>
<input type="submit" id="accept-button" class="btn btn-primary" id="accept-button" style="display: none;" value="ACCEPT CALL"/>
</form>
<%= javascript_pack_tag 'notifications' %>

View File

@ -47,10 +47,10 @@ Rails.application.configure do
# Mount Action Cable outside main process or domain # Mount Action Cable outside main process or domain
# config.action_cable.mount_path = nil # config.action_cable.mount_path = nil
config.action_cable.url = 'wss://polyglot-173.herokuapp.com/cable' config.action_cable.url = 'wss://polyglot-173.herokuapp.com/cable'
config.action_cable.allowed_request_origins = [ '*' ] config.action_cable.allowed_request_origins = [ 'https://polyglot-173.herokuapp.com/', 'https://www.polyglot.live' ]
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
# config.force_ssl = true config.force_ssl = true
# Use the lowest log level to ensure availability of diagnostic information # Use the lowest log level to ensure availability of diagnostic information
# when problems arise. # when problems arise.

View File

@ -1,5 +1,5 @@
Rails.application.routes.draw do Rails.application.routes.draw do
get 'video_sessions/create'
devise_for :users, path: '', path_names: { sign_out: 'logout'} devise_for :users, path: '', path_names: { sign_out: 'logout'}
devise_scope :user do devise_scope :user do
get '/logout', to: 'devise/sessions#destroy' get '/logout', to: 'devise/sessions#destroy'
@ -15,10 +15,23 @@ Rails.application.routes.draw do
get '/call', to: 'pages#call' get '/call', to: 'pages#call'
get '/establish_call/:contact_id', to: 'pages#establish_call', as: 'establish_call'
post '/accept_call', to: 'pages#accept_call', as: 'accept_call/'
patch '/accept_call/:request_id', to: 'requests#update', as: 'update_request'
get '/contacts', to: 'pages#index' get '/contacts', to: 'pages#index'
get '/setting', to: 'users#setting' get '/setting', to: 'users#setting'
post '/sessions', to: 'video_sessions#create' post '/sessions', to: 'video_sessions#create'
post '/chat_rooms/chat_room_sessions', to: 'chat_rooms#create'
resources :chat_rooms, only: [ :show ] do
# testing action cable
post '/cable_testing', to: 'pages#cable_testing'
end
mount ActionCable.server, at: '/cable' mount ActionCable.server, at: '/cable'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
get '/home', to: 'pages#home' get '/home', to: 'pages#home'

View File

@ -0,0 +1,8 @@
class CreateChatRooms < ActiveRecord::Migration[5.2]
def change
create_table :chat_rooms do |t|
t.timestamps
end
end
end

View File

@ -0,0 +1,10 @@
class CreateChatRoomParticipations < ActiveRecord::Migration[5.2]
def change
create_table :chat_room_participations do |t|
t.references :user, foreign_key: true
t.references :chat_room, foreign_key: true
t.timestamps
end
end
end

View File

@ -0,0 +1,11 @@
class CreateRequests < ActiveRecord::Migration[5.2]
def change
create_table :requests do |t|
t.references :user, foreign_key: true
t.references :chat_room, foreign_key: true
t.boolean :accepted
t.timestamps
end
end
end

View File

@ -0,0 +1,5 @@
class ChangeColumnInRequests < ActiveRecord::Migration[5.2]
def change
change_column :requests, :accepted, :boolean, default: false
end
end

View File

@ -10,11 +10,27 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_08_30_100110) do
# ActiveRecord::Schema.define(version: 2018_08_30_100110) do
ActiveRecord::Schema.define(version: 2018_08_29_123204) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
create_table "chat_room_participations", force: :cascade do |t|
t.bigint "user_id"
t.bigint "chat_room_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["chat_room_id"], name: "index_chat_room_participations_on_chat_room_id"
t.index ["user_id"], name: "index_chat_room_participations_on_user_id"
end
create_table "chat_rooms", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "connections", force: :cascade do |t| create_table "connections", force: :cascade do |t|
t.bigint "user_id" t.bigint "user_id"
t.bigint "contact_id" t.bigint "contact_id"
@ -22,6 +38,16 @@ ActiveRecord::Schema.define(version: 2018_08_30_100110) do
t.index ["user_id"], name: "index_connections_on_user_id" t.index ["user_id"], name: "index_connections_on_user_id"
end end
create_table "requests", force: :cascade do |t|
t.bigint "user_id"
t.bigint "chat_room_id"
t.boolean "accepted", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["chat_room_id"], name: "index_requests_on_chat_room_id"
t.index ["user_id"], name: "index_requests_on_user_id"
end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false t.string "encrypted_password", default: "", null: false
@ -41,6 +67,10 @@ ActiveRecord::Schema.define(version: 2018_08_30_100110) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end
add_foreign_key "chat_room_participations", "chat_rooms"
add_foreign_key "chat_room_participations", "users"
add_foreign_key "connections", "users" add_foreign_key "connections", "users"
add_foreign_key "connections", "users", column: "contact_id" add_foreign_key "connections", "users", column: "contact_id"
add_foreign_key "requests", "chat_rooms"
add_foreign_key "requests", "users"
end end

View File

@ -3,6 +3,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@rails/webpacker": "3.5", "@rails/webpacker": "3.5",
"actioncable": "^5.2.1",
"bootstrap": "3", "bootstrap": "3",
"jquery": "^3.3.1" "jquery": "^3.3.1"
}, },

View File

@ -0,0 +1,7 @@
require 'test_helper'
class ChatRoomsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class RequestsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class ChatRoomParticipationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class ChatRoomTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -0,0 +1,7 @@
require 'test_helper'
class RequestTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -57,6 +57,10 @@ acorn@^5.0.0:
version "5.7.2" version "5.7.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.2.tgz#91fa871883485d06708800318404e72bfb26dcc5" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.2.tgz#91fa871883485d06708800318404e72bfb26dcc5"
actioncable@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/actioncable/-/actioncable-5.2.1.tgz#615428a8a302cec55117d9049f0fd7952011c962"
ajv-keywords@^3.1.0: ajv-keywords@^3.1.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a"