Skip to content

Latest commit

 

History

History

docs

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="css/tether-min.css">
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/style.css">
    <link href="img/favicon.ico" rel="shortcut icon" type="image/vnd.microsoft.icon" >
  </head>
  <body>

    <div class="container">

      <div class="title">
        <h1>Middleware architectures in PHP with Zend Expressive</h1>
        <h3><a href="http://2017.websummercamp.com/PHP" target="_new">Web Summer Camp 2017 - Rovinj, Croatia</a></h3>
        <p><a href="https://twitter.com/maraspin" target="_new">Steve Maraspin</a> | <a href="https://twitter.com/marcoshuttle" target="_new">Marco Perone</a></p>
        <h4>MV Labs</h4>
      </div>

      <h2>Exercises</h2>

      <h3>1.Icebreaker (10m)</h3>
      <p>Create an <span class="purple">hello</span> route (matching requests for <a href="http://phpmiddleware.websc/hello">http://phpmiddleware.websc/hello</a> - we'll omit the server name from now on) which displays a greeting message in the form of a JSON response similar to <span class="purple">{"hello":"NAME"}</span>, where NAME is either the value provided by the name parameter in an http GET query string, or the string <span class="purple">random phper</span> if no name parameter was provided.
      </p>
      <p>Start from branch <span class="purple"><strong>03-welcome</strong></span> and remember the importance of the <span class="code">config/routes.php</span> file, and of the Action folder path, which is <span class="code">src/App/Action</span>. Also, remember that you can access request parameters from your process method of a middleware compliant action through the request parameter, which implements the PSR-7 compliant <a href="http://www.php-fig.org/psr/psr-7/">ServerRequestInterface interface</a> (<span class="code">Psr\Http\Message\ServerRequestInterface</span> in <span class="code">vendor/psr/http-message</span>).</p>

      <h3>2.Interacting with the Domain (25m)</h3>

      <p>Branch <span class="purple"><strong>06-chocolates-route</strong></span> contains a <span class="purple">chocolates</span> route (matching requests for <span class="purple">/chocolates</span>), allowing us to obtain a list of available chocolate wrappers.</p>

      <h4>Alternative A - Display chocolate detail</h4>
      Starting from branch <span class="purple"><strong>06-chocolates-route</strong></span>, we'd like to create a <span class="purple">chocolate-details</span> route (matching requests for <span class="purple">/chocolate/ID</span> where ID is a proper ID of our domain), allowing us to see the details for a specific chocolate wrapper. This means you will need to work with routes and parameters, but you will also need to interact with the domain (...providing all the tools you need, anyways).</p>
      <p>Relevant to us in this case are the <span class="code">config/routes.php</span> file, <span class="code">src/App/Domain/Service/ChocolatesService.php</span> service (and its <span class="code">getChocolate()</span> method, in particular) and, as a consequence, the <span class="code">src/App/Domain/Value/ChocolateId.php</span> value object - which can be created, in our action, as follows: <span class="code">ChocolateId::fromString(ID)</span> (where ID is the parameter passed from URL). By the way, since we're using <a href="https://packagist.org/packages/nikic/fast-route">nikic/FastRoute</a> as a router, we need to follow its syntax rules. To obtain (and validate) the the ID parameter from the URL, we do use the <span class="code">/chocolate/{id:[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}}</span> string as our route path to be matched in <span class="code">config/routes.php</span>. Then, within our action, if we want to fetch such value, we need to keep in mind that it will be stored within an attribute named the same way as the route parameter (IE id in this case); the <span class="code">getAttribute()</span> (or <span class="code">getAttributes()</span>) methods of the <span class="code">ServerRequestInterface</span> will be your friends. Also, existing action <span class="code">app/src/App/Action/ChocolatesAction.php</span> can turn out handy to get some inspiration.</p>

      <h4>Alternative B - Display user list</h4>
      Starting from branch <span class="purple"><strong>06-chocolates-route</strong></span>, we'd like to create a <span class="purple">users</span> route (matching requests for <span class="purple">/users</span>), allowing us to see a list of registered users. This means you will need to interact with the domain (which provides all the tools you need anyways).</p>
      <p>Relevant to us in this case are the <span class="code">config/routes.php</span> file and the <span class="code">src/App/Domain/Service/UsersService.php</span> service (with its <span class="code">getAll()</span> method, in particular). Also, existing class <span class="code">app/src/App/Action/ChocolatesAction.php</span> can turn out handy to get some inspiration. Finally, we can check out and ensure that all the services we need are declared (<span class="code">App\ConfigProvider</span> is our friend).</p>


      <h3>3.Caching Responses (25m)</h3>
      <p>There are many situations where application performances benefit from caching. Your task for this exercise is to enable caching for all GET requests handled by your application. This means that after a request has been received for the first time for a URL, the response needs to be saved in some way (IE within a local file). The next time the same GET request (IE same URL) is made, the cache content is fetched and returned, so that no interaction with the domain (IE database, other services, ...) is needed.</p>
      <p>Start from branch <span class="purple"><strong>10-access-log</strong></span>, where you have an example of some Middleware - IE a class implementing <span class="code">Interop\Http\ServerMiddleware\MiddlewareInterface</span> (which you can find it within the vendor/http-interop folder, or <a href="https://github.com/php-fig/fig-standards/blob/master/proposed/http-middleware/middleware.md">here</a>). Remember that you can add Middleware to your application not only through the <span class="code">config/routes.php</span> file (which we've seen so far), but also through the <span class="code">config/pipeline.php</span> file, which allows for the middleware to be globally included within the middleware pipeline. It would be good if some parameters (IE cached files path on the filesystem) could be set within the configuration of our app (the <span class="code">config/autoload</span> folder shall be used in this case). The <span class="code">data</span> folder is a good candidate target on the filesystem to keep our cache files. <span class="code">src/App/Container/Middleware/AccessLogFactory.php</span> can provide some inspiration on how to create middleware from factories (and also how to fetch relevant configuration parameters from content within <span class="code">config/autoload</span> configuration files). Also, don't forget about the <span class="code">config/autoload/dependencies.global.php</span> file, where dependencies need to be registered.</p>

      <h3>
      4.Dealing with Auth & Auth (30m)
      </h3>
      <p>
        We would like to implement JWT authentication on our system and allow only a subset of users to perform certain actions (IE commands) on our domain. In particular, we would like for the following restrictions to be applied on our chocolate wrappers:
        <ul>
        <li><strong>Submit</strong>: allowed to (all) logged users only</li>
        <li><strong>Approve</strong>: allowed to administrators only</li>
        <li><strong>Delete</strong>: allowed to administrators only</li>
        </ul>
      </p>

     <h4>Alternative A - Implement JWT Authentication</h4>
      <p>Start from branch <span class="purple"><strong>17.1-jwt-authentication</strong></span> and replace basic http authentication with JWT based authentication for the routes where authentication is required.</p>
      <p>
        Modify file <span class="code">config/autoload/dependencies.global.php</span> so to use <span class="code">App\Container\Middleware\JwtAuthenticationFactory</span>. Route <span class="purple">token</span> accepts a POST request with user credentials and returns a TOKEN_VALUE, which we can then use for subsequent requests requiring authentication. In order to generate a TOKEN_VALUE, you can use <span class="purple">postman</span>, with a <span class="purple">POST</span> action towards <span class="purple">http://phpmiddleware.websc/token/</span> with values <span class="purple">admin</span> as the username and <span class="purple">password</span> as the password. It's now time to take care of subsequent requests (where the generated token will need to be passed as an header: <span class="code">Authorization: Bearer TOKEN_VALUE</span>). On <span class="code">SubmitChocolateAction</span>, <span class="code">ApproveChocolateAction</span> and <span class="code">DeleteChocolateAction</span> (namespace <span class="code">App\Action</span>) you need to update the code retrieving the username from the request. To find out the name of such parameter within the request, you can take a look at <span class="code">App\Container\Middleware\JwtAuthenticationFactory</span> where the name of such parameter is defined through the <span class="purple">attribute</span> element in the array argument passed to the <span class="code">JwtAuthentication()</span> method. Also, <span class="code">Slim\Middleware\JwtAuthentication</span> requires some configuration. File <span class="code">config/local.php.dist</span> contains basic configuration structure. You need to modify it with a secret key, and ensure that the file is included within configuration (you can check out inclusion rules within <span class="code">config/config.php</span>).
      </p>

      <h4>Alternative B - Implement Authorization</h4>
      <p>Start from branch <span class="purple"><strong>17.1-basic-http-authentication</strong></span> and implement some authorization middleware (<span class="code">src/App/Middleware</span> folder is a good place where to keep it).</p>
      <p>First off remember that you'll need to act on routing (<span class="code">config/routes.php</span>) in order to enforce authorization checks on the routes needing it. On the middleware, you can set up things to basically reject all requests when a request for the <strong>Approve</strong> or <strong>Delete</strong> actions comes from something different than an admin user. In order to achieve this, we need to get the (authenticated) username from the request, fetch the related user from our domain (userservice ...) and then make sure that she has administrator privileges.</p>

      <h3>5.Testing (bonus)</h3>
      <p>Time's strict, so we focused on Middleware specific aspects. Also, we thought it would've been a good idea to interact with our application through Postman, so to get a glimpse of what's going on under the hood, at the http level. Tests were therefore a bit of an intentional leftover. We expect everyone's culture to drive daily development and are confident that proper care for code will be taken in everyday situations. With this said, if you've finished the exercises above early, and since it never hurts to do bit of testing, let's dig a bit into that too.</p>
      <p>From branches <span class="purple"><strong>19-phpunit</strong></span> or <span class="purple"><strong>20-behat</strong></span>, head to <span class="code">test/AppTest/Middleware</span> (or <span class="code">features/bootrap</span>) folders and fill existing test cases for <a href="https://phpunit.de/">PHPUnit</a> and <a href="http://behat.org/en/latest/">Behat</a> tests respectively.

      <h2>Branch Recap</h2>

      <h3>01-expressive-skeleton</h3>
      <p>This is what you get with a fresh Zend Expressive install (see <a href="http://zend-expressive.readthedocs.io/en/latest/#installation">documentation</a> and <a href="https://github.com/zendframework/zend-expressive-skeleton">Zend Expressive skeleton</a>).</p>

      <h3>02-clean-skeleton</h3>
      <p>Zend Expressive Skeleton contains stuff for a sample application which we won't use at this workshop. This branch gets rid of that.</p>

      <h3>03-welcome</h3>
      <p>We define our first custom route (for <span class="purple">/</span>), displaying a simple welcome message. Most relevant files for this branch are <span class="code">config/routes.php</span> and <span class="code">src/App/Action/IndexAction.php</span>.</p>

      <h3>04-hello-name</h3>
      <p>We extend previous branch, by adding a second (<span class="purple">hello</span>) route, which takes a(n optional) <span class="purple">name</span> parameter from the request and displays a personalized greeting. Most relevant files for this branch are <span class="code">config/routes.php</span> and <span class="code">src/App/Action/HelloAction.php</span>. Provides a solution to <span class="purple"><strong>exercise 1</strong></span>.</p>

      <h3>05-domain</h3>
      <p>Our application deals with chocolate wrappers. Too bad we haven't much time to spend either writing domain code, or sampling chocolate bars. At any case, this branch contains all <span class="purple">domain logic</span> and database related structure/code - see below for initialization scripts - for our application.</p>

      <h3>06-chocolates-route</h3>
      <p>This is an important branch, since interaction between our Zend Expressive application and our domain logic finally begins. The <span class="purple">chocolates</span> route is added, and a list of available chocolates is retrieved and displayed when the <span class="purple">/chocolates</span> URL is invoked. No doubt, most relevant file for this branch is <span class="code">app/src/App/Action/ChocolatesAction.php</span>. Also important are <span class="code">app/src/App/Container/Action/ChocolatesActionFactory.php</span> and <span class="code">app/src/App/ConfigProvider.php</span></p>

      <h3>07-chocolate-details-route</h3>
      <p>This branch extends previous one, as it adds a <span class="purple">chocolate-details</span> route, from which details for a single chocolate wrapper can be obtained, though the <span class="purple">/chocolate/ID</span> URL. In this case, most relevant file is <span class="code">app/src/App/Action/ChocolateDetailsAction.php</span>. Provides a solution to <span class="purple"><strong>exercise 2A</strong></span>.</p>

      <h3>08-users</h3>
      <p>Route <span class="purple">users</span> is added. A list of system users can be obtained through the <span class="purple">/users</span> URL invocation. Relevant file here is: <span class="code">app/src/App/Action/UsersAction.php</span>. Provides a solution to <span class="purple"><strong>exercise 2B</strong></span>.</p>

      <h3>09-user-details</h3>
      <p>Similar to what was happening between branches <span class="purple">06-chocolates-route</span> and <span class="purple">07-chocolate-details-routes</span>, we extend <span class="purple">08-users</span> here, adding  <span class="purple">user-details</span> route. Check out <span class="code">app/src/App/Action/UserDetailsAction.php</span>.</p>

      <h3>10-access-log</h3>
      <p>This is another important branch, as we add some Middlware taking care of cross cutting concerns of our application. In particular, we log all access requests. To do so we build some middleware through the <span class="code">src/App/Container/Middleware/AccessLogFactory.php</span> Factory (we'll rely upon <a href="https://packagist.org/packages/middlewares/access-log">middlewares/access-log</a> Middleware and <a href="https://packagist.org/packages/monolog/monolog">Monolog</a> library), and then enable it within the <span class="purple">config/pipeline.php</span> file. Another relevant file is <span class="code">config/autoload/dependencies.global.php</span> where dependencies for cross cutting concerns (IE the logging middleware, in this case) are declared.</p>

      <h3>11-response-cache</h3>
      <p>As in the previous branch, we add some middleware for taking care of cross cutting concerns. In contrast with what we've done before, instead of relying on existing middleware, we now implement some middleware ourselves (<span class="code">src/App/Middleware/ResponseCache.php</span>), as we can see within <span class="code">src/App/Container/Middleware/ResponseCacheFactory.php</span>. Provides a solution to <span class="purple"><strong>exercise 3</strong></span>.</p>

      <h3>12-error-reporting</h3>
      <p><a href="https://packagist.org/packages/los/api-problem">Api Problem</a> Middleware addded in order to improve API error handling.</p>

      <h3>13-submit-chocolate</h3>
      <p>Adds the <span class="purple">submit</span> route (accepting an http <span class="purple">POST</span> request), whose invokation results in a new chocolate wrapper being added to the system. File <span class="code">config/routes.php</span> is involved, of course, with a new route definition. Most relevant file is <span class="code">src/App/Action/SubmitChocolateAction.php</span>, though. You will notice that - since <span class="code">ChocolatesServiceInterface</span>'s <span class="code">submit()</span> method requires a parameter of type <span class="code">App\Domain\Entity\User</span>, but no users credentials have ever been provided - an existing user is fetched from the domain through the hardcoded username <span class="purple">user</span>.</p>

      <h3>14-approve-chocolate</h3>
      <p>Adds the <span class="purple">approve</span> route (accepting an http <span class="purple">POST</span> request), whose invokation results in a new chocolate wrapper to be made visible within the system. Most relevant file is contained within <span class="code">src/App/Container/Action</span>.</p>

      <h3>15-delete-chocolate</h3>
      <p>Adds the <span class="purple">delete</span> route (accepting an http <span class="purple">POST</span> request), whose invokation results in a chocolate wrapper to be flagged as removed from the system. Most relevant file is contained within <span class="code">src/App/Container/Action</span>.</p>

      <h3>16-basic-http-authentication (and related)</h3>
      <p>Deals with http basic authentication. Relevant files here are <span class="code">src/App/Container/Middleware/BasicHttpAuthenticationFactory.php</span> (you will notice we rely upon <a href="https://packagist.org/packages/middlewares/http-authentication">middlewares/http-authentication</a> Middleware) and, of course, <span class="code">config/routes.php</span>.</p>

      <h3>17-jwt-authentication (and related)</h3>
      <p>Deals with JWT authentication. Most relevant file here is <span class="code">src/App/Container/Middleware/JwtAuthenticationFactory.php</span> (you will notice we rely upon <a href="https://packagist.org/packages/slim/middleware">Middleware</a> written for <a href="https://www.slimframework.com/">Slim</a>. Welcome reuse! Last branch of the series provides a solution to <span class="purple"><strong>exercise 4A</strong></span>.</p>

      <h3>18-authorization</h3>
      <p>Combines features of branches 1 to 15 with authentication (17), in order to enforce authentication for all actions which might results in data changes within the application. Provides a solution to <span class="purple"><strong>exercise 4B</strong></span>.</p>

      <h3>19-phpunit (and related)</h3>
      <p>Creates <a href="https://phpunit.de/">PHPUnit</a> tests within <span class="code">test/AppTest/Middleware</span> folder to test the previously created <span class="code">App\Middleware\Authorization</span> middleware. Provides part of the solution to <span class="purple"><strong>exercise 5</strong></span>.</p>

      <h3>19-behat (and related)</h3>
      <p>Creates <a href="http://behat.org/en/latest/">Behat</a> features within <span class="code">features</span> folder to test the previously created <span class="code">App\Middleware\Authorization</span> middleware. Provides part of the solution to <span class="purple"><strong>exercise 5</strong></span>.</p>

      <h3>21-hal (and related)</h3>
      <p><a href="https://en.wikipedia.org/wiki/Hypertext_Application_Language">HAL</a> is a simple format that gives a consistent and easy way to hyperlink between resources in your API. This branch implements responses for GET requests following the HAL specifications.</p>


      <h2>Environment</h2>

      <h3>Database</h3>
      <p>The database used during this workshop (from branch <span class="purple"><strong>05-domain</strong> onwards) is <span class="code">phpmiddleware</span>. Everything will be set up at the beginning and should work out of the box, so that you won't have to worry about that.</p>
      <p>If you want to start from scratch on your own, you need to perform the following:
      <ul>
        <li>Create a PostgreSQL database</li>
        <li>Check out branch <span class="purple"><strong>05-domain</strong> (or later)</li>
        <li>Modify <span class="code">config/autoload/database.global.php</span> so that such db is referenced</li>
        <li>Execute <span class="code">php bin/migrations.php</span></li>
        <li>Execurte <span class="code">bin/seed</span> (available from branch <strong>06-chocolates-route</strong>)</li>
      </ul>
      You're ready to go!
      </p>

      <h3>Postman</h3>
      <p>Postman comes installed in the Workshop virtual machine. You can use following sample data (Body field, "Bulk Edit" option) to make chocolate submit requests:</p>
      <p><span class="code">
producer_name:MVLabs<br>
producer_street:unknown<br>
producer_number:0<br>
producer_zip_code:33033<br>
producer_city:Udine<br>
producer_region:UD<br>
producer_country:IT<br>
chocolate_description:100% Italy<br>
chocolate_percentage:100<br>
chocolate_wrapper_type:box<br>
chocolate_quantity:100<br>
      </span>
      </p>


      <h2>Zend Expressive Key Concepts Recap</h2>

      <h3>Folder Structure</h3>
      <p>Folder structure for this project is very similar to the one introduced <a href="http://zend-expressive.readthedocs.io/en/latest/reference/usage-examples/">here</a>. Below you can find a breakdown of each folder's content:

      <pre><code class="hljs stylus">.
      ├── bin <span class="purple">(utilities - IE cache clearing / DB migrations utilities)</span>
      ├── config <span class="purple">(configuration files)</span>
      ├── data <span class="purple">(misc data, such as cache and log files)</span>
      ├── docs <span class="purple">(documentation)</span>
      ├── features <span class="purple">(Behat features)</span>
      ├── migrations
      ├── public <span class="purple">(document root)</span>
      │&nbsp;&nbsp; └── index<span class="hljs-class">.php</span>
      ├── seeds <span class="purple">(data for database initialization)</span>
      ├── src <span class="purple">(this is where we will be keeping most of our code)</span>
      ├── test <span class="purple">(PHPUnit tests)</span>
      └── vendor <span class="purple">(code provided by third parties)</span>
      </code></pre>

      </p>

      <h3>DI/IoC Container</h3>
      <p>Expressive promotes and advocates the usage of Dependency Injection/Inversion of Control. The Application instance itself stores a container, from which it fetches middleware (as well as other objects) when time comes to dispatch/use them. Container dependencies are defined within the <span class="code">getDependencies()</span> method of the <span class="code">src/ConfigProvider.php</span> file (where we keep application/domain related dependencies) or the <span class="code">config/autoload/dependencies.global.php</span> file (where we keep foundational/cross cutting concern related dependencies). This means that, if a class is created through a Factory, such factory will need to be specified within those files.</p>

      <h3>Configuration Parameters</h3>
      <p>The config directory keeps Zend Expressive configuration files. Important files herein are the following:
      <ul>
      <li><span class="code">config/pipeline.php</span> dealing with application wide middleware</li>
      <li><span class="code">config/routes.php</span> dealing with routes - and related middleware</li>
      <li><span class="code">config.php</span> dealing with configuration for the application, in general</li>
      </ul>

      Within this file, we see that a <span class="code">Zend\ConfigAggregator\ConfigAggregator</span> object is instantiated, and that a merged configuration is retrieved from it. Such configuration encompasses different configuration sources among which there are:
      <ul>
        <li>our <span class="code">App\ConfigProvider</span> configuration</li>
        <li>the content of several files within the <span class="code">config/autoload</span> subfolder</li>
        <li><span class="code">config/development.config.php</span></li>
      </ul>
      The idea is that filenames matching the <span class="code">config/autoload/*.local.php</span> pattern (referring to specific host configuration) overwrite the configuration of files matching the <span class="code">config/autoload/*.global.php</span> pattern (baseline configuration) and that, in turn, they are both overwritten by the <span class="code">config/development.config.php</span> file.</p>
      <p>The DI/IoC container we mentioned before is passed as an argument to Factories. Configuration parameters can then be extracted from it using the <span class="code">$container->get('config');</span> call within a Factory's invoke method (where $container is the <span class="code">__invoke()</span>'s method first parameter).</p>

      <h3>Adding Middleware</h3>
      <p>Our own middleware will generally reside within the <span class="code">src/App/Middleware</span> folder, whereas third party middleware will be kept within the <span class="code">vendor</span> folder.</p>
      <p>Whatever the location of middleware on the file system, in order for it to be used in our applications, we need to deal with its creation (which is usually done through factories, within the <span class="code">src/App/Container/Middleware</span> folder - with actions being taken care of from the <span class="code">src/App/Container/Action</span> folder) and then activation, which can be system wide. Middleware will then be enabled through the <span class="code">config/pipeline.php</span>, or on a per route/request basis through the <span class="code">config/routes.php</span> file.</p>
      <p>If you're curious to know how things work under the hood, you can check out <span class="code">Zend\Expressive\MarshalMiddlewareTrait</span> which takes care of generating middleware.</p>

      <h2>Frequent Issues</h2>
      <h3>Fatal error: Uncaught ArgumentCountError: Too few arguments to function Something\Something::__construct()</h3>
      <p>This error is usually caused by configuration issues. Configuration caching can be a source of pain here; a quick attempt to fix this kind of issue is to run the clear-config-cache.php script within the bin folder - IE invoke <span class="code">composer clear-config-cache</span> from the CLI.</p><p>If you still cannot get things to work, make sure you check the content of your <span class="code">src/ConfigProvider.php</span> file, especially in the <span class="code">getDependencies()</span> method and check namespace declarations.</p>

      <h3>Fatal error: Uncaught Error: Class 'Something' not found in /home/websc/ze-workshop/config/container.php:31 Stack trace: #0</h3>
      <p>When skipping from a banch to another, things might go awry with composer dependencies. Although we did the possible to always keep all needed dependencies within the project, in rare situations, you might face some issues with missing dependencies. In such a situation, before you despair, remember you can invoke <span class="code">composer install</span> from the project root. Most often than not this solves similar issues. If not, there might be something missing among your namespace declarations at the beginning of some file.</p>

      <h3>Heeelp! I'm stuck with Git and cannot follow the workshop anymore!</h3>
      <p>If your problem is about uncommitted files, confilicts or other issues regarding git, we've created a command which can be used as a last resort, especially if you don't know git well. Try to run <span class="code">bin/square1</span>, then try to position yourself on one of the branches above and see if everything now goes smoothly. Your changes won't be lost, and they will be kept within a branch called websc_DATETIME.</p>


      </div>

      <footer class="footer">
        <div class="container">
          <div class="row justify-content-end">
            <div class="col-3 text-lg-center">A workshop by <a href="http://www.mvlabs.it/" target="_blank" rel="noopener noreferrer" title="MVlabs"><img src="img/mv-logo.png" alt="MVlabs"></a><p class="thanks">Thank you for being a part of it!</p>
            </div>
          </div>
        </div>
      </footer>

    <!-- jQuery first, then Tether, then Bootstrap JS. -->
    <script src="js/jquery-3.2.1.min.js"></script>
    <script src="js/tether.min.js"></script>
    <script src="js/bootstrap.min.js"></script>

  </body>
</html>