BUILDING A RESTFUL WEB APP WITHANGULAR.JS AND BEAR.SUNDAY
RICHARD MCINTYRE@MACKSTAR
GOAL RESTFUL CMSFRONT-END ANGULAR.JSBACK-END BEAR.SUNDAY
WHY ANOTHER CMS?
▸ Building RESOURCES▸ Power through simplicity▸ Easy overrides with AOP▸ Kill plugin culture
▸ Build re-usable libraries
CONTINUED...
▸ Embed in any PHP project▸ Testable
▸ Relationships between resources / Hypermedia▸ Templating through TWIG
BEAR.SUNDAY▸ REST▸ DI▸ AOP
In most MVC application frameworks, CRUD and OOP paradigms are mapped
to HTTP methods and resources. But the opposite is not true. - Akihito Koriyama
EVERYTHING IS A RESOURCERequest: /api/resources/index?_start=1
Request Method: GETStatus Code: 200 OK
{ "resources": [ { "id": "16", "slug": "2332", "type": "blog", "title": "2332", "type_name": "Blog", "title_label": "Title" } ], "_model": "resources", "_pager": { "maxPerPage": 5, "current": "1", "total": 1, "hasNext": false, "hasPrevious": false }, "_links": { "self": { "href": "spout/app/resources/index/?_start=1" } }}
Request: /api/resources/typesRequest Method: POSTStatus Code: 200 OK
{ "title_label": "Title", "resource_fields": [ { "field_type": { "id": "1", "name": "String", "slug": "string" }, "multiple": 0, "weight": 1, "label": "Body", "slug": "body" } ], "name": "Page", "slug": "page"}
Request: /api/menus/links?menu=primaryRequest Method: GETStatus Code: 200 OK
{ "links": [ { "id": "6", "name": "Link 1", "url": "/url1", "type": "url", "resource": null, "resource_type": null, "weight": "999", "depth": null, "parent_id": "0", "menu": "primary" }, { "id": "7", "name": "Link 2", "url": "/url2", "type": "url", "resource": null, "resource_type": null, "weight": "999", "depth": null, "parent_id": "0", "menu": "primary" } ], "_model": "links"}
namespace Mackstar\Spout\Admin\Resource\App\Resources;
/** * Resources Index * * @Db */class Index extends ResourceObject{
/** * @Link(rel="type", href="app://self/resources/detail?type={slug}&slug={slug}") * @DbPager(5) */ public function onGet() { $sql = "SELECT {$this->table}.*, type.name as type_name, type.title_label FROM {$this->table} "; $sql .= "INNER JOIN resource_types AS type "; $sql .= "ON type.slug = {$this->table}.type";
$stmt = $this->db->query($sql); $this['resources'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this; }
CUSTOM RESOURCES CAN CALL OTHER RESOURCES
public function onGet($id = null) { $this['types'] = $this->resource->get->uri('app://self/resources/types') }
RESOURCES OF A TYPE HAVE RESOURCE AND RESOURCE INDEX RELATIONSHIPS
OVERRIDES THROUGH AOP▸ Validation▸ Permissions
▸ Template Switching▸ Other custom events
DEPENDENCIESINJECTED
namespace Mackstar\Spout\Admin\Interceptor\Users;
use Ray\Aop\MethodInterceptor;use Ray\Aop\MethodInvocation;use Ray\Di\Di\Inject;use Symfony\Component\HttpFoundation\Session\Session as PhpSession;
class Session implements MethodInterceptor{
private $session;
/** * @Inject */ public function setSession(PhpSession $session) { $this->session = $session; }
public function invoke(MethodInvocation $invocation) { $response = $invocation->proceed(); $response->body['_user'] = $this->session->get('user'); return $response; }
BIND USING MODULES
private function installUserSessionAppender() { $session = $this->requestInjection('\Mackstar\Spout\Admin\Interceptor\Users\Session');
$this->bindInterceptor( $this->matcher->subclassesOf('BEAR\Resource\ResourceObject'), $this->matcher->startsWith('onGet'), [$session] ); }
TESTS
namespace Mackstar\Spout\Admin\Test\Interceptor\Validators;
use Mackstar\Spout\Admin\Interceptor\Validators\UserValidator;
class UserValidatorTest extends \PHPUnit_Framework_TestCase{
public function testErrorsWhenNoEmailIsPassedIn() { ... }}
FRONT-END INTEGRATION{{ resource | app://spout/resources/detail?type=photo-gallery&slug=spout-is-live | detail-blog.twig }}
PAGINATION{{ resource | app://spout/resources/index?type=blog | index-blog.twig | paged(1)}}
MENU{{ resource | app://spout/menus?slug=primary | primary-menu.twig}}
ARCHITECTURE
ANGULAR.JSADMIN
BE YOUR OWNCONSUMER
COMPONENTS▸ Routes▸ Directives▸ Controllers▸ Services
2 WAY DATA BINDING
TEMPLATE<input type="email" ng-model="login.email" required>
CONTROLLERscope.$watch('login.email', function () { console.log("Login Email Changed To:" + scope.login.email);});
ROUTESANGULAR-UI ROUTER
app.config(['$stateProvider', function($stateProvider) {
$stateProvider.state('login', { url: "/login", controller: 'LoginCtrl', templateUrl: '/js/templates/login/index.html', resolve: { authentication: ['Restangular', function (Restangular) { return Restangular.all('users/authenticate'); }] } }) .state('logout', { url: "/logout", controller: "LogoutCtrl", resolve: { authenticate: ['Restangular', function (Restangular) { return Restangular.all('users/authenticate'); }] } });
}]);
DIRECTIVES<SP-THUMBNAIL />
app.directive('spThumbnail', function (Restangular) { return { restrict: 'E', template: "<img src='/img/spinner.gif' ng-click='select()' />", scope: { media: "=media"}, replace: true, link: function(scope, element, attrs) {
var src = '/uploads/media/' + scope.media.directory + '/140x140_' + scope.media.file, img = new Image();
function loadImage() { element[0].src = src; }
img.src = src; img.onerror = function() { Restangular.all('media/resize').post( {media: scope.media, height: 140, width: 140} ).then(function() { loadImage(); }); };
...
DEPENDENCY INJECTION
Declarevar module = angular.module('restangular', []);module.provider('Restangular', function() {}
Implementvar app = angular.module('myApp', ['restangular']);
app.controller('MyController', ['Restangular', function (Restangular) { Restangular.all('users/authenticate').get().then(function(auth) { ... });}]);
TESTS
describe('Roles Directive', function () {
var scope, $element;
beforeEach(function () { module('Application'); angular.mock.inject( function ($rootScope, $compile) { var element = angular.element('<roles-selector></roles-selector>'); scope = $rootScope; scope.roles = [{"id": 1, "name": "Admin"},{"id": 2, "name": "Contributor"}]; $compile(element)(scope); scope.$digest(); $element = $(element); } ); });
it('should have a select menu', function () { expect($element.prop("tagName")).toEqual('SELECT'); expect($element.find("option").length).toBe(2); });
DEMO
STUFF TO DO▸ Security
▸ DB/API Schema lock-down▸ Create as composer component / Assetic▸ More field types (locations/times/md etc)
▸ Blocks▸ Get others input
GET INVOLVEDGITHUB.COM/MACKSTAR/SPOUT