Skip to content

Instantly share code, notes, and snippets.

@FraGoTe
Forked from HarryR/Video.php
Last active August 29, 2015 14:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save FraGoTe/b17f00cfd90f551b44df to your computer and use it in GitHub Desktop.
Save FraGoTe/b17f00cfd90f551b44df to your computer and use it in GitHub Desktop.
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<!-- Hosting provided by cdnjs.com / CloudFlare, rather than a local copy -->
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/json2/20110223/json2.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/jquery/1.7/jquery.min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.2.2/underscore-min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/0.5.3/backbone-min.js"></script>
<link rel="stylesheet" type="text/css" media="all" href="http://cssgrid.net/css/1140.css" />
<link rel="stylesheet" media="all" href="style.css" />
</head>
<body lang="en" id="appview">
<div class="container header">
<div class="row">
<h1>Header</h1>
</div>
</div>
<div class="container footer">
<div class="row">
<h1>Footer</h1>
</div>
</div>
<div class="container main-content">
<div class="row">
<div class="sidebar fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class="eightcol middle-col last">
<div class="container top-navigation">
<div class="row">
<h4>Top Navigation</h4>
</div>
</div><!-- .top-navigation -->
<div class="container results">
<div class="row">
<div class="fivecol">
<div class="widget_thumbnails" id="example_placement">
Thumbanails
</div>
</div>
<div class="sevencol last">
<div class="widget_comments">
Recent Searches
</div>
</div>
</div>
</div><!-- .results -->
</div><!-- .main-content -->
</div><!-- .row -->
<div class="row">
<div class="sidebar fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class=" fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class=" fourcol last">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
</div>
<div class="row">
<div class="sidebar fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class="eightcol middle-col last">
<div class="container top-navigation">
<div class="row">
<h4>Top Navigation</h4>
</div>
</div><!-- .top-navigation -->
<div class="container results">
<div class="row">
<div class="fivecol">
<div class="widget_thumbnails">
Thumbanails
</div>
</div>
<div class="sevencol last">
<div class="widget_comments">
Recent Searches
</div>
</div>
</div>
</div><!-- .results -->
</div><!-- .main-content -->
</div><!-- .row -->
<div class="row">
<div class="sidebar fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class="eightcol middle-col last">
<div class="container top-navigation">
<div class="row">
<h4>Top Navigation</h4>
</div>
</div><!-- .top-navigation -->
<div class="container results">
<div class="row">
<div class="fivecol">
<div class="widget_thumbnails">
Thumbanails
</div>
</div>
<div class="sevencol last">
<div class="widget_comments">
Recent Searches
</div>
</div>
</div>
</div><!-- .results -->
</div><!-- .main-content -->
</div><!-- .row -->
<div class="row">
<div class="sidebar fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class=" fourcol">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
<div class=" fourcol last">
<div class="container widget_container">
Sidebar
</div>
</div><!-- .sidebar -->
</div>
</div>
</body>
<!-- underscore implements micro templating and it's great to use with type="text/template" script tags -->
<script type="text/template" id="TPL_VIDEO">
<div class="video_group">
<% _.each ( videos, function(v) { %>
<div class="video">
<div class="title"><% v.get('TITLE') %></div>
<div class="thumb_container">
<img src="http://cdn.sometube.example.com/<% v.get('ID') %>.thumb40x30.jpg" />
</div>
<div class="description">
<%= v.get('DESCRIPTION') %>
</div>
<a href="#video/<%= v.get('ID') %>">Watch</a>
<!-- Display more properties -->
</div>
<% }); %>
</div>
</script>
<script type="text/javascript">
$(document).ready( function(){
Video = Backbone.Model.extend({
isVideo: function() {
// Videos have title and description,
// tags have body and name
return this.has('title');
}
});
VideoView = Backbone.View.extend({
el: $("#example_placement"), // Every view has a element associated with it
renderList: function( collection ){
var compiled_template = _.template( $("#TPL_VIDEO").html() );
collection.videoview.el.html( compiled_template( { videos: collection.models } ) );
}
});
VideoCollection = Backbone.Collection.extend({
model: Video, // Set the collection to use the NewsEntry model
url: 'video',
initialize: function(){
// TODO: bind VideoList to use different template and container div
this.videoview = new VideoView;
this.bind("refresh", this.videoview.renderList);
}
});
AppView = Backbone.View.extend({
el: $("#appview"),
initialize: function(){
// Lets create an empty collection to store the news
this.videos = new VideoCollection;
},
events: {
"keypress #searchbox": "loadResults"
},
// TODO: make model do this
loadResults: function(event){
query = $(event.currentTarget).val();
$.when( this.ajaxGetVideos( query ) )
.then(
$.proxy(
function( response ){
this.videos.refresh( response );
}, this ) );
},
ajaxGetVideos: function( query ){
return $.ajax("video", { dataType: "json" });
}
});
// Create an app view once the document has loaded
var appview = new AppView;
})
</script>
</html>
For a weekend project I decided to brush up on the Zend framework and revisit Oracle XE for use in a small project. Rather than a prototypical Blog engine the subject of the project will be an Enterprisey approach to generic 'tube sites'.
Due to time constraints I'll be steaming through four phases of development: Architecture, Model Development, View Construction and the obligatory Bells & Whistles.
The API layer will consist of:
* PHP 5.3
* Oracle XE
* Zend Framework
The UX layer will consist of:
* Model: REST endpoint
* Controller: Backbone.js
* View: HTML, CSS and Underscore.js templates
Platform:
* Ubuntu and LXC containers
* Apache or nginx
Basic features required for the project should match sites and software packages on the market today, take carptube.com as an example*. In addition to listing videos and associated meta information it allows interaction by recording statistics and allowing users to vote on qualities of a video.
As an initial goal it would be nice to have video listing and tag following as the primary modes of site navigation, so allow social interaction with the site tags with an associated comment can be added to videos.
To prevent flooding and bots from abusing the site it would be advantageous to take security in mind and use a captcha service like reCAPTCHA, a proxy/firewall service like CloudFlair and additional IP based restrictions such as: one comment per IP per 10 minutes.
* Note: I have no affiliation with carptube.com, it's simply as an example and any critique is in ernest. I couldn't determine which Tube software it's running or if it's custom written, otherwise I would have linked to the software creators.
Schema
------------
Using a two table limit it as an arbitrary restriction while trying to justif new features restricts the scope of the project and focuses effort on improving the core functionality and building a solid application.
The two tables are:
[Video]
| Id
| ViewCount
| TagCount
| ...
[Tag]
| Video.Id
| Rating
| Name
| Comment
| ...
All interaction goes into the [Tag] table, while the stats columns in [Video] are updated when a video is viewed or tagged/commented. By keeping all interaction data together it can be analysed faster than if it were overly normalized.
All fields of the 'Tag' table will be required, this forces people to tag, rate and comment on a video at the same time.
Video properties to consider:
| Url - String
- To follow the KISS principals all that will be used to display a video is an iFrame and URL.
- However, it could be interpreted like: plugin://content-type/video-id?param=..
| Duration - Number(5,2)
- This should be accurate and very portable across database systems but special care should be taken when converting from decimal to minutes/seconds.
- The convention being used is:
1 second = (1.0/60) = 0.0166667
2 min 10 seconds = 130*(1.0/60) = 2.16667
- Note: this will have fuzzy accuracy of over 1 second when used with Number(5,2).
Oracle schema:
CREATE table "VIDEO" (
"ID" NUMBER(10,0) NOT NULL,
"TITLE" NVARCHAR2(100),
"DESCRIPTION" NVARCHAR2(500),
"WHEN_ADDED" DATE,
"VIEW_LAST" DATE,
"VIEW_COUNT" NUMBER(10,0),
"TAG_COUNT" NUMBER(6,0),
"URL" VARCHAR2(400),
constraint "VIDEO_PK" primary key ("ID")
)
/
CREATE table "TAG" (
"ID" NUMBER(10,0) NOT NULL,
"VIDEO_ID" NUMBER(10,0) NOT NULL,
"WHEN_ADDED" DATE NOT NULL,
"NAME" VARCHAR2(20) NOT NULL,
"BODY" NVARCHAR2(500) NOT NULL,
"RATING" NUMBER(2,0) NOT NULL,
constraint "TAG_PK" primary key ("ID")
)
/
ALTER TABLE "TAG" ADD CONSTRAINT "TAG_FK"
FOREIGN KEY ("VIDEO_ID")
REFERENCES "VIDEO" ("ID")
ON DELETE CASCADE
/
CREATE sequence "VIDEO_SEQ"
/
CREATE trigger "BI_VIDEO"
before insert on "VIDEO"
for each row
begin
select "VIDEO_SEQ".nextval into :NEW.ID from dual;
end;
/
CREATE trigger "BI_TAG"
before insert on "TAG"
for each row
begin
select "VIDEO_SEQ".nextval into :NEW.ID from dual;
end;
/
Application Design
-----------------------------
The application will be split into three tiers which are analogous to model: Oracle, controller: Apache+PHP and view: HTML+Javascript. Interaction between the view and controller will be via managed by the UI script over HTTP and will be restricted to providing data only (e.g. a plain REST/JSON interface). Ineraction between the Controller and Model will be available using SQL.
I considered using `Zend_Rest_Server` and mod_rewrite provide a REST structure and access pattern that is easy port to other backends, e.g. a fast Python or C implementation of the API. The advantages are that you get precise control over all URLs and their functionality, with the downsides being the additional code and configuration required.
Using an iFrame to display the video within a specific region allows another tier to be split out to a dedicated and highly cached service, a referer-hiding SSL redirect or even an external provider like YouTube.
To take scalability into consideration it would be possible to partition the table with comments being stored on the same shard, in such a configuration each shard would have a database shard replcia and an API server.
As the number of videos and comments increase the more effort that's required to perform full-text search across the dataset, an option is to utilize a more specialist search engine for all possible read-only queries. Candidates include SphinxSearch, ElastiSearch or RiakSearch depending on the performance, data size and scale-out requirements.
The Zend_Db_Table interface is also mostly database agnostic allowing for such small application to be ported and deployed across different databases.
Environment Preparation
---------------------------------------
To install Oracle XE 11 on Ubuntu Oneiric the Oracle provided RPM package needs to be converted to a .deb before. This can be done using the Alien, a package converter which supports several formats.
$ sudo alien -i oracle-xe-11.2.0-1.0.x86_64.rpm.zip
Follow Oracle guides to setup your ORACLE_HOME and get started with the database. For convenience I link `bashrc -> bin/oracle_env.sh` for the oracle user.
Debian and Ubuntu currently don't provide the PDO_OCI module, but it is included in the source. Use APT to download the sources for available PHP version.
The pdo_oci module depends on an internal PDO header file called 'php_pdo_driver.h', to make the header files available they can be installed locally within the source directory.
$ sudo apt-get install build-dep php5
$ apt-get source php5
$ cd php5-*
$ ./configure --prefix=`pwd`
$ make install-headers
However the linker driver expects the '.so' suffix and the oracle-xe 11.2 package provides libclntsh.so.11.1 --
/usr/bin/ld.bfd.real: cannot find -lclntsh
collect2: ld returned 1 exit status
make: *** [pdo_oci.la] Error 1
This can be temporarily rectified with a symlink before building and installing the module.
$ sudo ln -s $ORACLE_HOME/lib/libclntsh.so.* $ORACLE_HOME/lib/libclntsh.so
$ cd ext/pdo_oci
$ ./configure --with-php-config=../../scripts/php-config
$ make
$ sudo cp modules/pdo_oci.so `php -i | grep extension_dir | cut -f 3 -d ' '`
The same procedure can be used for any extension which requires Oracle Client libraries. With Zend Server this isn't such an arduous task, but it depends on what you're comfortable installing and maintaining. For a development system this will be sufficient.
Zend Framework Configuration
------------------------------------------------
To enable the application to access the Oracle XE instance a profile can be added in application.ini:
resources.db.isDefaultTableAdapter = true
resources.db.adapter = PDO_OCI
resources.db.params.dbname = "XE"
resources.db.params.port = "1521"
resources.db.params.charset = "utf8"
resources.db.params.host = "instance-hostname"
resources.db.params.username = "myUser"
resources.db.params.password = "myPass"
resources.db.params.options.autoQuoteIdentifiers = false
resources.db.params.options.caseFolding = 2
If developing from a URL path sub-directory remember to ammend the .htaccess file by adding a RewriteBase statement.
Setting up the models is as simple as two ZF commands to create models/DbTable/Tag.php andmodels/DbTable/Video.php:
$ zf create db-table Video VIDEO
$ zf create db-table Tag TAG
The ZF tool integration with IntelliJ allows this to be done inside the IDE with the shortcut Ctrl+Shift+X. The only addition step required is to hookup the Sequence shared by Video and Tag to the models by adding a variable to each model class:
protected $_sequence = 'VIDEO_SEQ';
To provide RESTful controllers the Feathry* library by David Luecke allows the interface to be as small as a wrapper between Zend_Db_Table and the implementation of get/put methods.
* https://github.com/feathry/feathry-library
After modifying the existing VideoController to become a Feathry Rest Controller I used IntelliJ to fill out the methods which need implementing, having used the editor for a number of years and recently upgrading to IntelliJ 11 - even while using Sublime Text for most tasks - it is an excellent and very slick project editor with full PHP support.
Example auto-generated implementation:
/**
* Return a list of all resources.
* @param $params A list of additional parameters
* given (e.g. paging and sorting parameters).
* @return array
*/
public function index($params = null)
{
// TODO: Implement index() method.
}
With the bare minimum of code the index() method can return a database result and output either XML or JSON using on Accept: auto negotiation. It is upto the client to specify the required content type, desktop browsers will receive the .phtml view for the controller unless it's disabled in application.ini
text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
To help testing I disabled the HTML, the next accepted content-type (XML) will be used for serialization.
In application.ini:
resources.rest.view.html = false
resources.rest.deserialize.html = false
Internationalisation
-------------------------------
Without configuring Oracle date formats or locale settings all dates will be displayed in the format of 'DD-MMM-YY', it would be preferable to be outputting data that's easily parsable & manipulated using common standard library functions. Zend_Db_Table doesn't handle Oracle DATE columns - a bug was already filed ZF-10701* which describes actual and expected behavior.
http://framework.zend.com/issues/browse/ZF-10701
Example:
<data>
<unknownNode>
<id>1</id>
<title>Test Title</title>
<description>Test Description</description>
<whenadded>18-DEC-11</whenadded>
<viewlast>18-DEC-11</viewlast>
<viewcount>1</viewcount>
<tagcount>1</tagcount>
<url>http://example.com/</url>
</unknownNode>
</data>
An easy workaround is to specify date format and client character set preferences upon every database connect.
protected function _initDatabase()
{
$this->bootstrap('db');
$db = $this->getResource('db');
// Force date format sanity
if( $db instanceof Zend_Db_Adapter_Pdo_Oci ) {
$db->query("alter session set NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'");
}
}
Data Validation and Model Sanity
----------------------------------------------------
Validation for model classes can be implemented by overriding the insert() and update() methods; one of the qualities of a refined framework or solidly built application is to allow model and data vaildation feedback to the user or system which provided the data.
I initially implemented basic restrictions on which fields can be passed, additional checks for data sanity and relational model validity are performed at RDBMS side and are part of the schema.
If the Model being written is the only interface with the model and is expected to work across several databases then more data checks would be added. With non-SQL backends like MongoDB, Redis or Riak the Model classes would be responsible for validit of all data.
function editableFieldsOf(array $data) {
return
array_intersect_assoc(
array_change_key_case($data, CASE_UPPER),
array(
'VIDEO_ID'=>1,
'NAME'=>1,
'BODY'=>1,
'RATING'=>1));
}
To make model validation available to other components like forms and do so in a consistent and standard way by utilizing the Zend_Filter_Input class and validators. The approach I would prefer would be where the Feathry REST controller detected if a filter is available for the method being called.
Demonstrating input and output validity of core APIs and functions can be expressed with the Design by Contract paradigm. Digital Mars D provides "in", "out" and "body" blocks as part of the function definition instead of the single code block prevalent in most imperative languages.
Pseudo-Code example:
module VideoModel:
Id = insert(data: array) ->
require ->
VIDEO_ID, NAME, BODY, RATING in data;
RATING between 0 and 5.
ensure ->
Id is not Null.
do ->
return db.insert(data).
end
Taking this approach supplements Unit and Regression tests in that it allows input and return values to be quantified against known properties the data should exhibit. Output data validation errors can be flagged with application monitoring services like NewRelic or with Zend Server to alert developers to potential data corruption and to aid with initial engineering.
Implementing it in PHP requires the code to be structured in one way or another to emulate DbC behavior and for invalidity conditions to be handled. The method in which error messages are passed must be considered to ensure they're available to the calling function the View where it's most useful.
With all components and layers that handle with user or model data 'plugged in' to the validation chain it makes developing robust applications easier; generally the less code required to hook up data to a chain of validation the better, the most direct method is associating HTTP parameters with a field (and associated validation filters) from from the model.
A previous approach I've taken is to use calling conventions for field validation and selection of validation utilities like database introspection* and provide user friendly error messages.
* http://trac.assembla.com/lithium-php/browser/tags/pre-lithium/lib/core/dbo.php
public function
checkEmail( $value ) {
parent::check( $value, 'email' );
C_Validate::isEmail( $value );
}
Rest Interface
----------------------
The Zend_Rest_Route class defines the routing of requests to the controller, the Zend documentation is well written and vast albeit sometimes hard to locate the most important information due to being structured like reference material.
Excerpt from: http://framework.zend.com/manual/en/zend.controller.router.html#zend.controller.router.routes.rest
GET /product/ratings/ Product_RatingsController::indexAction()
GET /product/ratings/:id Product_RatingsController::getAction()
POST /product/ratings Product_RatingsController::postAction()
PUT /product/ratings/:id Product_RatingsController::putAction()
DELETE /product/ratings/:id Product_RatingsController::deleteAction()
POST /product/ratings/:id?_method=PUT Product_RatingsController::putAction()
POST /product/ratings/:id?_method=DELETE
The point of binding between the UX and REST interface will be Backbone.js Collections; at this point there are two directions which can be taken:
1) A 1:1 mapping between Database Tables, the API and frontend Model.
or
2) Provide all data for application states.
An example use case for the API is to retrieve the data for the homepage, consider that the same item may be displayed multiple times, to facilitate this the 'state transfer' from REST in our case will be transferring items and names groups containing items from the returned set.
Example Homepage:
{'items': {'91482': {'title':...}, ...},
'popular': [13929, ...],
'latest': [13929, 91482, ...],
'newcomments': [48299, ...]
}
UI Templating
----------------------
There are numerous introductory tutorials for Backbone.js such as Building Epic Win* with Backbone, they use ASP.NET MVC to implement the server side -- viewing the definitions in a more strictly typed language makes it clearer to implement the API.
[HttpGet]
public ActionResult List() {
return Json(zones, JsonRequestBehavior.AllowGet);
}
[HttpDelete]
public ActionResult Delete(int id) {
return Json(new { id });
}
[HttpPost]
public ActionResult Create(DnsZone postData) {
return Json(new { id = dnsZone.Id });
}
* http://www.codethinked.com/building-epic-win-with-backbone-js
Template areas will be filled from named lists from the response:
<div class="sidebar">
<% _.each ( popular, function(p) { %>
<div class="video">
<img src="http://cdn.../<% p.id %>.thumb40x30.jpg" />
<label><% p.title %></label>
<a href="video/<%= p.safe_title%>.<%= p.id %>.html">Watch</a>
</div>
<% });%>
</div>
CSS Framework
-------------------------
Having used several CSS grid systems and developed for enough with different browsers to appreciate the benefits they bring. For this demo I used cssgrid.net, a fluid column system that adapts for mobile, tablet and desktop browser sizes.
While trying to create a layout resembling my original Photoshop wireframe the right-hand sidebar was a problem and didn't fit well after scaling. For this reason I switched the position to the left side which still allows for variation in placement and content sizes.
Using a fluid layout can be more effort, designing content and stylesheets fit into variable sized layouts takes time, forethought and influences later design choices. A related design choice would be to keep content in a corner specified in background-position so details aren't obscured as the boxes grow & shrink in size, it also reduces the need for multiple graphics for different devices.
Artwork specifications could include min+max dimensions and which corner.
It would be interesting to use LessCSS to handle multiple layout devices.
body {
background: #eee;
font-family: Ubuntu, Helvetica, Arial, sans-serif;
line-height: 23px;
color: #333;
padding-top:50px;
padding-bottom: 50px;
}
.container.header {
background: #D9D9D9;
min-height: 50px;
height: 50px;
padding-left: 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
box-shadow: 0px 0px 10px #000;
}
.container.footer {
background: #D9D9D9;
min-height: 50px;
height: 50px;
padding-left: 20px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
box-shadow: 0px 0px 10px #000;
}
.top-navigation {
background: #B9B9B9;
}
.widget_comments, .widget_thumbnails {
min-height: 200px;
}
.results {
background: #D9D9D9;
margin-top: 10px;
margin-bottom: 10px;
}
.sidebar {
background: red;
min-height: 240px;
}
.middle-col {
background: #fff;
}
.widget_container {
min-height: 250px;
background: green;
}
<?php
class Application_Model_DbTable_Video extends Zend_Db_Table_Abstract
{
protected $_name = 'VIDEO';
protected $_sequence = 'VIDEO_SEQ';
protected function
editableFieldsOf(array $data) {
return
array_intersect_assoc(
array_change_key_case($data, CASE_UPPER),
array(
'TITLE'=>1,
'DESCRIPTION'=>1,
'URL'=>1));
}
public function
insert(array $data) {
return
parent::insert(
$this->editableFieldsOf($data)
+ array(
'WHEN_ADDED' => new Zend_Db_Expr('CURRENT_TIMESTAMP'),
'VIEW_LAST' => new Zend_Db_Expr('CURRENT_TIMESTAMP'),
'VIEW_COUNT' => 0,
'TAG_COUNT' => 0));
}
public function
update(array $data, $where)
{
return
parent::update(
$this->editableFieldsOf($data), $where);
}
}
<?php
class VideoController extends Feathry_Rest_Controller implements Feathry_Rest_Resource
{
// Extract Video ID free-form URLs:
// /video/Carp-Fishing-Example.va83.html
protected function extractId($str) {
if( ctype_digit($str) ) return $str;
if( preg_match('@\.v(?P<video_id>[a-z0-9]+)\.@', $str, $m) ) {
return base_convert($m['video_id'], 36, 10);
}
throw new Exception("Cannot Find Video ID");
}
public function index($params = null)
{
$c = new Application_Model_DbTable_Video();
$summary = array();
$summary['latest']
= $c->fetchAll(
$c->select()
->order('WHEN_ADDED DESC')
->limit(100)
);
return $summary;
}
public function get($id, $params = null)
{
$id = $this->extractId($id);
$video_table = new Application_Model_DbTable_Video();
$summary = array();
$summary['video'] = $video_table->find((int)$id);
return $summary;
}
// POSTing to /video to add comments and rate videos
public function post($data, $params = null)
{
}
public function put($data, $id = null, $params = null)
{
/*
$video_table = new Application_Model_DbTable_Video();
return array(
'id' => $video_table->insert($params),
);
*/
}
public function delete($id, $params = null)
{ return array('error' => 'Cannot DELETE'); }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment