diff --git a/.gitignore b/.gitignore
index e33bc3a0cb098911b613d4daa5df5672dc9995ed..881c5f0b9d8b72615cee44d7fdd31109a22d244e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ composer.lock
 composer.phar
 import/solrmarc.log
 node_modules
+public/swagger-ui
diff --git a/composer.json b/composer.json
index 084739668e8d89dc514df4fd89430cb05238e31b..e9328e9372405e6140e03709e08886330dea3c6d 100644
--- a/composer.json
+++ b/composer.json
@@ -21,6 +21,7 @@
         "pear/validate_ispn": "dev-master",
         "serialssolutions/summon": "1.0.0",
         "symfony/yaml": "2.7.6",
+        "swagger-api/swagger-ui": "2.2.4",
         "vufind-org/vufindcode": "1.0.3",
         "vufind-org/vufindharvest": "2.1.0",
         "vufind-org/vufindhttp": "2.1.1",
@@ -39,5 +40,8 @@
         "phpunit/phpunit": "4.8.27",
         "sebastian/phpcpd": "2.0.0",
         "squizlabs/php_codesniffer": "2.6.0"
-    }
+    },
+    "scripts": {
+        "post-update-cmd": "rm -rf public/swagger-ui; cp -r vendor/swagger-api/swagger-ui/dist public/swagger-ui; sed -i.orig \"s/defaultModelRendering: 'schema'/defaultModelRendering: 'model'/\" public/swagger-ui/index.html; sed -i.orig 's/url = \".*\"/url = \"..\\/api\\/v1\\?swagger\"/' public/swagger-ui/index.html"
+    } 
 }
diff --git a/config/application.config.php b/config/application.config.php
index 5a39bff34ca019f7e409732fc9e1118badaab701..6a2033d10d1f416204e1883381851323d2495d01 100644
--- a/config/application.config.php
+++ b/config/application.config.php
@@ -2,7 +2,7 @@
 
 // Set up modules:
 $modules = array(
-    'ZfcRbac', 'VuFindTheme', 'VuFindSearch', 'VuFind', 'VuFindAdmin'
+    'ZfcRbac', 'VuFindTheme', 'VuFindSearch', 'VuFind', 'VuFindAdmin', 'VuFindApi'
 );
 if (PHP_SAPI == 'cli' && !defined('VUFIND_PHPUNIT_RUNNING')) {
     $modules[] = 'VuFindConsole';
diff --git a/config/vufind/SearchApiRecordFields.yaml b/config/vufind/SearchApiRecordFields.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..ea2a7dd6bc5ae4c4ea67227d10a83e85db767da0
--- /dev/null
+++ b/config/vufind/SearchApiRecordFields.yaml
@@ -0,0 +1,375 @@
+# Key is the field name that can be requested. It has the following subkeys:
+# - method name to call (either in the SearchApiController class or the record driver)
+# - default: true if the field is displayed by default when the request does not specify fields
+# - Swagger specification fields describing the returned data.
+#
+# See http://swagger.io/specification/ for information on the Swagger-specific fields
+#
+accessRestrictions:
+  method: getAccessRestrictions
+  description: Access restriction notes
+  type: array
+  items:
+    type: string
+authors:
+  method: getDeduplicatedAuthors
+  default: true
+  description: >-
+    Deduplicated author information including main, corporate and secondary
+    authors
+  type: array
+  items:
+    $ref: '#/definitions/Authors'
+awards:
+  method: getAwards
+  description: Award notes
+  type: array
+  items:
+    type: string
+bibliographicLevel:
+  method: getBibliographicLevel
+  description: Bibliographic level
+  type: string
+  enum:
+    - Monograph
+    - Serial
+    - MonographPart
+    - SerialPart
+    - Collection
+    - CollectionPart
+    - Unknown
+bibliographyNotes:
+  method: getBibliographyNotes
+  description: Bibliography notes
+  type: array
+  items:
+    type: string
+callNumbers:
+  method: getCallNumbers
+  description: Call numbers
+  type: array
+  items:
+    type: string
+childRecordCount:
+  method: getChildRecordCount
+  description: Number of child records
+  type: integer
+cleanDoi:
+  method: getCleanDOI
+  description: First valid DOI
+  type: string
+cleanIsbn:
+  method: getCleanISBN
+  description: First valid ISBN favoring ISBN-10 over ISBN-13 when possible
+  type: string
+cleanIssn:
+  method: getCleanISSN
+  description: Base portion of the first listed ISSN
+  type: string
+cleanOclcNumber:
+  method: getCleanOCLCNum
+  description: First OCLC number
+  type: string
+containerEndPage:
+  method: getContainerEndPage
+  description: End page in the containing item
+  type: string
+containerIssue:
+  method: getContainerIssue
+  description: Issue number of the containing item
+  type: string
+containerReference:
+  method: getContainerReference
+  description: Reference to the containing item
+  type: string
+containerStartPage:
+  method: getContainerStartPage
+  description: Start page in the containing item
+  type: string
+containerTitle:
+  method: getContainerTitle
+  description: Title of the containing item
+  type: string
+containerVolume:
+  method: getContainerVolume
+  description: Volume of the containing item
+  type: string
+corporateAuthors:
+  method: getCorporateAuthors
+  description: Main corporate authors
+  type: array
+  items:
+    type: string
+dedupIds:
+  method: "Formatter::getDedupIds"
+  description: IDs of all records deduplicated with the current record
+  type: array
+  items:
+    type: string
+edition:
+  method: getEdition
+  description: Edition
+  type: string
+findingAids:
+  method: getFindingAids
+  description: Finding aids
+  type: array
+  items:
+    type: string
+formats:
+  method: getFormats
+  default: true
+  description: Formats
+  type: array
+  items:
+    type: string
+fullRecord:
+  method: "Formatter::getFullRecord"
+  description: Full metadata record (typically XML)
+  type: array
+  items:
+    type: string
+generalNotes:
+  method: getGeneralNotes
+  description: General notes
+  type: array
+  items:
+    type: string
+geoLocations:
+  method: getGeoLocation
+  description: Geographic locations (e.g. points, bounding boxes)
+  type: array
+  items:
+    type: string
+hierarchicalPlaceNames:
+  method: getHierarchicalPlaceNames
+  description: Hierarchical place names concatenated for display
+  type: array
+  items:
+    type: string
+hierarchyParentId:
+  method: getHierarchyParentId
+  description: Parent record IDs for hierarchical records
+  type: array
+  items:
+    type: string
+hierarchyParentTitle:
+  method: getHierarchyParentTitle
+  description: Parent record titles for hierarchical records
+  type: array
+  items:
+    type: string
+hierarchyTopId:
+  method: getHierarchyTopId
+  description: Hierarchy top record IDs for hierarchical records
+  type: array
+  items:
+    type: string
+hierarchyTopTitle:
+  method: getHierarchyTopTitle
+  description: Hierarchy top record titles for hierarchical records
+  type: array
+  items:
+    type: string
+humanReadablePublicationDates:
+  method: getHumanReadablePublicationDates
+  description: Publication dates in human-readable format
+  type: array
+  items:
+    type: string
+id:
+  method: getUniqueID
+  default: true
+  description: Record unique ID (can be used in the record endpoint)
+  type: string
+institutions:
+  method: getInstitutions
+  description: Institutions the record belongs to
+  type: array
+  items:
+    type: string
+isbns:
+  method: getISBNs
+  description: ISBNs
+  type: array
+  items:
+    type: string
+isCollection:
+  method: isCollection
+  description: Whether the record is a collection node in a hierarchy
+  type: boolean
+issns:
+  method: getISSNs
+  description: ISSNs
+  type: array
+  items:
+    type: string
+languages:
+  method: getLanguages
+  default: true
+  description: Languages
+  type: array
+  items:
+    type: string
+lccn:
+  method: getLCCN
+  description: LCCNs
+  type: array
+  items:
+    type: string
+newerTitles:
+  method: getNewerTitles
+  description: Successor titles
+  type: array
+  items:
+    type: string
+oclc:
+  method: getOCLC
+  description: OCLC numbers
+  type: array
+  items:
+    type: string
+openUrl:
+  method: getOpenUrl
+  description: OpenURL
+  type: string
+physicalDescriptions:
+  method: getPhysicalDescriptions
+  description: Physical dimensions etc.
+  type: array
+  items:
+    type: string
+placesOfPublication:
+  method: getPlacesOfPublication
+  description: Places of publication
+  type: array
+  items:
+    type: string
+playingTimes:
+  method: getPlayingTimes
+  description: Playing times (durations)
+  type: array
+  items:
+    type: string
+previousTitles:
+  method: getPreviousTitles
+  description: Predecessor titles
+  type: array
+  items:
+    type: string
+primaryAuthors:
+  method: getPrimaryAuthors
+  description: Primary authors
+  type: array
+  items:
+    type: string
+productionCredits:
+  method: getProductionCredits
+  description: Production credits
+  type: array
+  items:
+    type: string
+publicationDates:
+  method: getPublicationDates
+  description: Publication dates
+  type: array
+  items:
+    type: string
+publishers:
+  method: getPublishers
+  description: Publishers
+  type: array
+  items:
+    type: string
+rawData:
+  method: "Formatter::getRawData"
+  description: All data in the index fields
+  type: string
+recordLinks:
+  method: getAllRecordLinks
+  description: Links to other related records
+  type: array
+  items:
+    $ref: '#/definitions/RecordLink'
+recordPage:
+  method: "Formatter::getRecordPage"
+  description: Link to the record page in the UI
+  type: string
+relationshipNotes:
+  method: getRelationshipNotes
+  description: Notes describing relationships to other items
+  type: array
+  items:
+    type: string
+secondaryAuthors:
+  method: getSecondaryAuthors
+  description: Secondary authors
+  type: array
+  items:
+    type: string
+series:
+  method: getSeries
+  default: true
+  description: Series
+  type: array
+  items:
+    type: string
+shortTitle:
+  method: getShortTitle
+  description: Short title (title excluding any subtitle)
+  type: string
+subjects:
+  method: getAllSubjectHeadings
+  default: true
+  description: Subject headings
+  type: array
+  items:
+    type: string
+subTitle:
+  method: getSubTitle
+  description: Subtitle
+  type: string
+summary:
+  method: getSummary
+  description: Summary
+  type: array
+  items:
+    type: string
+systemDetails:
+  method: getSystemDetails
+  description: Technical details on the represented item
+  type: array
+  items:
+    type: string
+targetAudienceNotes:
+  method: getTargetAudienceNotes
+  description: Notes about the target audience
+  type: array
+  items:
+    type: string
+title:
+  method: getTitle
+  default: true
+  description: Title including any subtitle
+  type: string
+titleSection:
+  method: getTitleSection
+  description: Part/section portion of the title
+  type: string
+titleStatement:
+  method: getTitleStatement
+  description: Statement of responsibility that goes with the title
+  type: string
+toc:
+  method: getTOC
+  description: Table of contents
+  type: array
+  items:
+    type: string
+urls:
+  method: "Formatter::getURLs"
+  default: true
+  description: URLs contained in the record
+  type: array
+  items:
+    $ref: '#/definitions/Url'
diff --git a/config/vufind/permissions.ini b/config/vufind/permissions.ini
index d61244aa0fd647890027809c907da64ad8c49ee9..ee7fe291f1772fa8a989259868ef1b16181e6667 100644
--- a/config/vufind/permissions.ini
+++ b/config/vufind/permissions.ini
@@ -145,3 +145,11 @@ permission = access.StaffViewTab
 [ezproxy.authorized]
 permission = ezproxy.authorized
 role = loggedin
+
+; Search and Record API permissions.
+;[api.SearchAndRecord]
+;permission[] = access.api.Search
+;permission[] = access.api.Record
+;require = ANY
+;ipRange[] = '127.0.0.1'
+;ipRange[] = '::1'
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index 456e96dd848622ef0607e72834614a576994bf72..71cecb52bd9c02c3e8a78c7dfadc08adbdca5465 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -234,6 +234,7 @@ $config = [
             'VuFind\Tags' => 'VuFind\Service\Factory::getTags',
             'VuFind\Translator' => 'VuFind\Service\Factory::getTranslator',
             'VuFind\WorldCatUtils' => 'VuFind\Service\Factory::getWorldCatUtils',
+            'VuFind\YamlReader' => 'VuFind\Service\Factory::getYamlReader',
         ],
         'invokables' => [
             'VuFind\HierarchicalFacetHelper' => 'VuFind\Search\Solr\HierarchicalFacetHelper',
diff --git a/module/VuFind/src/VuFind/Cache/Manager.php b/module/VuFind/src/VuFind/Cache/Manager.php
index 7dcd720a2db4eee499c377ac438ea6f7c5b30df6..835c43fa543faeaaecea7354b6fd9901dbcdb4e9 100644
--- a/module/VuFind/src/VuFind/Cache/Manager.php
+++ b/module/VuFind/src/VuFind/Cache/Manager.php
@@ -89,7 +89,7 @@ class Manager
         $cacheBase = $this->getCacheDir();
 
         // Set up standard file-based caches:
-        foreach (['config', 'cover', 'language', 'object'] as $cache) {
+        foreach (['config', 'cover', 'language', 'object', 'yaml'] as $cache) {
             $this->createFileCache($cache, $cacheBase . $cache . 's');
         }
         $this->createFileCache('public', $cacheBase . 'public');
diff --git a/module/VuFind/src/VuFind/Config/SearchSpecsReader.php b/module/VuFind/src/VuFind/Config/SearchSpecsReader.php
index d772c346e30ba51cc73be9f42cb18387234205c6..e49deb4c3eea0efedbbf1d42892bf03bbec1c373 100644
--- a/module/VuFind/src/VuFind/Config/SearchSpecsReader.php
+++ b/module/VuFind/src/VuFind/Config/SearchSpecsReader.php
@@ -26,7 +26,6 @@
  * @link     https://vufind.org Main Site
  */
 namespace VuFind\Config;
-use Symfony\Component\Yaml\Yaml;
 
 /**
  * VuFind SearchSpecs Configuration Reader
@@ -37,22 +36,8 @@ use Symfony\Component\Yaml\Yaml;
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Site
  */
-class SearchSpecsReader
+class SearchSpecsReader extends YamlReader
 {
-    /**
-     * Cache manager
-     *
-     * @var \VuFind\Cache\Manager
-     */
-    protected $cacheManager;
-
-    /**
-     * Cache of loaded search specs.
-     *
-     * @var array
-     */
-    protected $searchSpecs = [];
-
     /**
      * Constructor
      *
@@ -60,96 +45,7 @@ class SearchSpecsReader
      */
     public function __construct(\VuFind\Cache\Manager $cacheManager = null)
     {
-        $this->cacheManager = $cacheManager;
-    }
-
-    /**
-     * Return search specs
-     *
-     * @param string $filename config file name
-     *
-     * @return array
-     */
-    public function get($filename)
-    {
-        // Load data if it is not already in the object's cache:
-        if (!isset($this->searchSpecs[$filename])) {
-            $this->searchSpecs[$filename] = $this->getFromPaths(
-                Locator::getBaseConfigPath($filename),
-                Locator::getLocalConfigPath($filename)
-            );
-        }
-
-        return $this->searchSpecs[$filename];
-    }
-
-    /**
-     * Given core and local filenames, retrieve the searchspecs data.
-     *
-     * @param string $defaultFile Full path to file containing default YAML
-     * @param string $customFile  Full path to file containing local customizations
-     * (may be null if no local file exists).
-     *
-     * @return array
-     */
-    protected function getFromPaths($defaultFile, $customFile = null)
-    {
-        // Connect to searchspecs cache:
-        $cache = (null !== $this->cacheManager)
-            ? $this->cacheManager->getCache('searchspecs') : false;
-
-        // Generate cache key:
-        $cacheKey = basename($defaultFile) . '-'
-            . (file_exists($defaultFile) ? filemtime($defaultFile) : 0);
-        if (!empty($customFile)) {
-            $cacheKey .= '-local-' . filemtime($customFile);
-        }
-        $cacheKey = md5($cacheKey);
-
-        // Generate data if not found in cache:
-        if ($cache === false || !($results = $cache->getItem($cacheKey))) {
-            $results = $this->parseYaml($customFile, $defaultFile);
-            if ($cache !== false) {
-                $cache->setItem($cacheKey, $results);
-            }
-        }
-
-        return $results;
-    }
-
-    /**
-     * Process a YAML file (and its parent, if necessary).
-     *
-     * @param string $file          YAML file to load (will evaluate to empty array
-     * if file does not exist).
-     * @param string $defaultParent Parent YAML file from which $file should
-     * inherit (unless overridden by a specific directive in $file). None by
-     * default.
-     *
-     * @return array
-     */
-    protected function parseYaml($file, $defaultParent = null)
-    {
-        // First load current file:
-        $results = (!empty($file) && file_exists($file))
-            ? Yaml::parse(file_get_contents($file)) : [];
-
-        // Override default parent with explicitly-defined parent, if present:
-        if (isset($results['@parent_yaml'])) {
-            $defaultParent = $results['@parent_yaml'];
-            // Swallow the directive after processing it:
-            unset($results['@parent_yaml']);
-        }
-
-        // Now load in missing sections from parent, if applicable:
-        if (null !== $defaultParent) {
-            foreach ($this->parseYaml($defaultParent) as $section => $contents) {
-                if (!isset($results[$section])) {
-                    $results[$section] = $contents;
-                }
-            }
-        }
-
-        return $results;
+        parent::__construct($cacheManager);
+        $this->cacheName = 'searchspecs';
     }
 }
diff --git a/module/VuFind/src/VuFind/Config/YamlReader.php b/module/VuFind/src/VuFind/Config/YamlReader.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e14bb4f7e1e1e63f7d6d45bbaa126419ace27b3
--- /dev/null
+++ b/module/VuFind/src/VuFind/Config/YamlReader.php
@@ -0,0 +1,162 @@
+<?php
+/**
+ * VuFind YAML Configuration Reader
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2010.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Config
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFind\Config;
+use Symfony\Component\Yaml\Yaml;
+
+/**
+ * VuFind YAML Configuration Reader
+ *
+ * @category VuFind
+ * @package  Config
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+class YamlReader
+{
+    /**
+     * Cache directory name
+     *
+     * @var string
+     */
+    protected $cacheName = 'yaml';
+
+    /**
+     * Cache manager
+     *
+     * @var \VuFind\Cache\Manager
+     */
+    protected $cacheManager;
+
+    /**
+     * Cache of loaded files.
+     *
+     * @var array
+     */
+    protected $files = [];
+
+    /**
+     * Constructor
+     *
+     * @param \VuFind\Cache\Manager $cacheManager Cache manager (optional)
+     */
+    public function __construct(\VuFind\Cache\Manager $cacheManager = null)
+    {
+        $this->cacheManager = $cacheManager;
+    }
+
+    /**
+     * Return a configuration
+     *
+     * @param string $filename config file name
+     *
+     * @return array
+     */
+    public function get($filename)
+    {
+        // Load data if it is not already in the object's cache:
+        if (!isset($this->files[$filename])) {
+            $this->files[$filename] = $this->getFromPaths(
+                Locator::getBaseConfigPath($filename),
+                Locator::getLocalConfigPath($filename)
+            );
+        }
+
+        return $this->files[$filename];
+    }
+
+    /**
+     * Given core and local filenames, retrieve the configuration data.
+     *
+     * @param string $defaultFile Full path to file containing default YAML
+     * @param string $customFile  Full path to file containing local customizations
+     * (may be null if no local file exists).
+     *
+     * @return array
+     */
+    protected function getFromPaths($defaultFile, $customFile = null)
+    {
+        // Connect to the cache:
+        $cache = (null !== $this->cacheManager)
+            ? $this->cacheManager->getCache($this->cacheName) : false;
+
+        // Generate cache key:
+        $cacheKey = basename($defaultFile) . '-'
+            . (file_exists($defaultFile) ? filemtime($defaultFile) : 0);
+        if (!empty($customFile)) {
+            $cacheKey .= '-local-' . filemtime($customFile);
+        }
+        $cacheKey = md5($cacheKey);
+
+        // Generate data if not found in cache:
+        if ($cache === false || !($results = $cache->getItem($cacheKey))) {
+            $results = $this->parseYaml($customFile, $defaultFile);
+            if ($cache !== false) {
+                $cache->setItem($cacheKey, $results);
+            }
+        }
+
+        return $results;
+    }
+
+    /**
+     * Process a YAML file (and its parent, if necessary).
+     *
+     * @param string $file          YAML file to load (will evaluate to empty array
+     * if file does not exist).
+     * @param string $defaultParent Parent YAML file from which $file should
+     * inherit (unless overridden by a specific directive in $file). None by
+     * default.
+     *
+     * @return array
+     */
+    protected function parseYaml($file, $defaultParent = null)
+    {
+        // First load current file:
+        $results = (!empty($file) && file_exists($file))
+            ? Yaml::parse(file_get_contents($file)) : [];
+
+        // Override default parent with explicitly-defined parent, if present:
+        if (isset($results['@parent_yaml'])) {
+            $defaultParent = $results['@parent_yaml'];
+            // Swallow the directive after processing it:
+            unset($results['@parent_yaml']);
+        }
+
+        // Now load in missing sections from parent, if applicable:
+        if (null !== $defaultParent) {
+            foreach ($this->parseYaml($defaultParent) as $section => $contents) {
+                if (!isset($results[$section])) {
+                    $results[$section] = $contents;
+                }
+            }
+        }
+
+        return $results;
+    }
+}
diff --git a/module/VuFind/src/VuFind/RecordDriver/SolrMarc.php b/module/VuFind/src/VuFind/RecordDriver/SolrMarc.php
index f90e2816c2b4a42f5eb9448b264be12f02dbec03..d16f7440294b8949a525ef070d648226d808e16e 100644
--- a/module/VuFind/src/VuFind/RecordDriver/SolrMarc.php
+++ b/module/VuFind/src/VuFind/RecordDriver/SolrMarc.php
@@ -207,6 +207,19 @@ class SolrMarc extends SolrDefault
         return $matches;
     }
 
+    /**
+     * Return full record as filtered XML for public APIs.
+     *
+     * @return string
+     */
+    public function getFilteredXML()
+    {
+        $record = clone($this->getMarcRecord());
+        // The default implementation does not filter out any fields
+        // $record->deleteFields('9', true);
+        return $record->toXML();
+    }
+
     /**
      * Get notes on finding aids related to the record.
      *
diff --git a/module/VuFind/src/VuFind/Search/Solr/HierarchicalFacetHelper.php b/module/VuFind/src/VuFind/Search/Solr/HierarchicalFacetHelper.php
index 14db33a94d86349de5a642fe9a756eeb74d3b9c8..c29b70535edddf8a1269ecbf1a7ee749da5667f1 100644
--- a/module/VuFind/src/VuFind/Search/Solr/HierarchicalFacetHelper.php
+++ b/module/VuFind/src/VuFind/Search/Solr/HierarchicalFacetHelper.php
@@ -86,6 +86,7 @@ class HierarchicalFacetHelper
      * @param string    $facet     Facet name
      * @param array     $facetList Facet list
      * @param UrlHelper $urlHelper Query URL helper for building facet URLs
+     * @param bool      $escape    Whether to escape URLs
      *
      * @return array Facet hierarchy
      *
@@ -93,13 +94,14 @@ class HierarchicalFacetHelper
      * converting-a-flat-array-with-parent-ids-to-a-nested-tree/
      * Based on this example
      */
-    public function buildFacetArray($facet, $facetList, $urlHelper = false)
-    {
+    public function buildFacetArray($facet, $facetList, $urlHelper = false,
+        $escape = true
+    ) {
         // Create a keyed (for conversion to hierarchical) array of facet data
         $keyedList = [];
         foreach ($facetList as $item) {
             $keyedList[$item['value']] = $this->createFacetItem(
-                $facet, $item, $urlHelper
+                $facet, $item, $urlHelper, $escape
             );
         }
 
@@ -177,10 +179,11 @@ class HierarchicalFacetHelper
      * @param string         $facet     Facet name
      * @param array          $item      Facet item received from Solr
      * @param UrlQueryHelper $urlHelper UrlQueryHelper for creating facet URLs
+     * @param bool           $escape    Whether to escape URLs
      *
      * @return array Facet item
      */
-    protected function createFacetItem($facet, $item, $urlHelper)
+    protected function createFacetItem($facet, $item, $urlHelper, $escape = true)
     {
         $href = '';
         $exclude = '';
@@ -189,14 +192,14 @@ class HierarchicalFacetHelper
             if ($item['isApplied']) {
                 $href = $urlHelper->removeFacet(
                     $facet, $item['value'], true, $item['operator']
-                )->getParams();
+                )->getParams($escape);
             } else {
                 $href = $urlHelper->addFacet(
                     $facet, $item['value'], $item['operator']
-                )->getParams();
+                )->getParams($escape);
             }
             $exclude = $urlHelper->addFacet($facet, $item['value'], 'NOT')
-                ->getParams();
+                ->getParams($escape);
         }
 
         $displayText = $item['displayText'];
diff --git a/module/VuFind/src/VuFind/Service/Factory.php b/module/VuFind/src/VuFind/Service/Factory.php
index 6593137e45e7fc47d4a1d3e4d9f0f81c8d5bbb23..d406c6a583be37062997b00458649f37f14252eb 100644
--- a/module/VuFind/src/VuFind/Service/Factory.php
+++ b/module/VuFind/src/VuFind/Service/Factory.php
@@ -932,4 +932,18 @@ class Factory
             $client, true, $ip
         );
     }
+
+    /**
+     * Construct the YAML reader.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return \VuFind\Config\YamlReader
+     */
+    public static function getYamlReader(ServiceManager $sm)
+    {
+        return new \VuFind\Config\YamlReader(
+            $sm->get('VuFind\CacheManager')
+        );
+    }
 }
diff --git a/module/VuFindApi/Module.php b/module/VuFindApi/Module.php
new file mode 100644
index 0000000000000000000000000000000000000000..b439aec4319007fd62624e8e0b8b5263581ab7b3
--- /dev/null
+++ b/module/VuFindApi/Module.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * VuFind Api module.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) The National Library of Finland 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Module
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development
+ */
+namespace VuFindApi;
+use Zend\ModuleManager\ModuleManager,
+    Zend\Mvc\MvcEvent;
+
+/**
+ * VuFind Api module.
+ *
+ * @category VuFind
+ * @package  Module
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development
+ */
+class Module
+{
+    /**
+     * Get module configuration
+     *
+     * @return array
+     */
+    public function getConfig()
+    {
+        return include __DIR__ . '/config/module.config.php';
+    }
+
+    /**
+     * Get autoloader configuration
+     *
+     * @return array
+     */
+    public function getAutoloaderConfig()
+    {
+        return [
+            'Zend\Loader\StandardAutoloader' => [
+                'namespaces' => [
+                    __NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
+                ],
+            ],
+        ];
+    }
+}
diff --git a/module/VuFindApi/config/module.config.php b/module/VuFindApi/config/module.config.php
new file mode 100644
index 0000000000000000000000000000000000000000..8214f56894a0de267144ba4b6866097ee2b2df52
--- /dev/null
+++ b/module/VuFindApi/config/module.config.php
@@ -0,0 +1,50 @@
+<?php
+namespace VuFindApi\Module\Configuration;
+
+$config = [
+    'controllers' => [
+        'factories' => [
+            'api' => 'VuFindApi\Controller\Factory::getApiController',
+            'searchapi' => 'VuFindApi\Controller\Factory::getSearchApiController',
+        ]
+    ],
+    'router' => [
+        'routes' => [
+            'apiHome' => [
+                'type' => 'Zend\Mvc\Router\Http\Segment',
+                'verb' => 'get,post,options',
+                'options' => [
+                    'route'    => '/api[/v1][/]',
+                    'defaults' => [
+                        'controller' => 'Api',
+                        'action'     => 'Index',
+                    ]
+                ],
+            ],
+            'searchApiv1' => [
+                'type' => 'Zend\Mvc\Router\Http\Literal',
+                'verb' => 'get,post,options',
+                'options' => [
+                    'route'    => '/api/v1/search',
+                    'defaults' => [
+                        'controller' => 'SearchApi',
+                        'action'     => 'search',
+                    ]
+                ]
+            ],
+            'recordApiv1' => [
+                'type' => 'Zend\Mvc\Router\Http\Literal',
+                'verb' => 'get,post,options',
+                'options' => [
+                    'route'    => '/api/v1/record',
+                    'defaults' => [
+                        'controller' => 'SearchApi',
+                        'action'     => 'record',
+                    ]
+                ]
+            ]
+        ],
+    ],
+];
+
+return $config;
diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiController.php b/module/VuFindApi/src/VuFindApi/Controller/ApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..391045b7af1d4ba383919bd78d4899c744f8ce0d
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Controller/ApiController.php
@@ -0,0 +1,123 @@
+<?php
+/**
+ * API Controller
+ *
+ * PHP Version 5
+ *
+ * Copyright (C) The National Library of Finland 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA    02111-1307    USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Controller;
+
+/**
+ * API Controller
+ *
+ * Controls the API functionality
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class ApiController extends \VuFind\Controller\AbstractBase
+{
+    use ApiTrait;
+
+    /**
+     * Array of available API controllers
+     *
+     * @var array
+     */
+    protected $apiControllers = [];
+
+    /**
+     * Add an API controller to the list of available controllers
+     *
+     * @param Zend\Mvc\Controller\AbstractActionController $controller API Controller
+     *
+     * @return void
+     */
+    public function addApi($controller)
+    {
+        if (!in_array($controller, $this->apiControllers)) {
+            $this->apiControllers[] = $controller;
+        }
+    }
+
+    /**
+     * Index action
+     *
+     * Return Swagger specification or redirect to Swagger UI
+     *
+     * @return \Zend\Http\Response
+     */
+    public function indexAction()
+    {
+        // Disable session writes
+        $this->disableSessionWrites();
+
+        if (null === $this->getRequest()->getQuery('swagger')) {
+            $urlHelper = $this->getViewRenderer()->plugin('url');
+            $base = rtrim($urlHelper('home'), '/');
+            $url = "$base/swagger-ui/?url="
+                . urlencode("$base/api?swagger");
+            return $this->redirect()->toUrl($url);
+        }
+        $response = $this->getResponse();
+        $headers = $response->getHeaders();
+        $headers->addHeaderLine('Content-type', 'application/json');
+        $config = $this->getConfig();
+        $params = [
+            'config' => $config,
+            'version' => \VuFind\Config\Version::getBuildVersion(),
+            'specs' => $this->getApiSpecs()
+        ];
+        $json = $this->getViewRenderer()->render('api/swagger', $params);
+        $response->setContent($json);
+        return $response;
+    }
+
+    /**
+     * Get specification fragments from all APIs as JSON
+     *
+     * @return string
+     */
+    protected function getApiSpecs()
+    {
+        $results = [];
+
+        foreach ($this->apiControllers as $controller) {
+            $api = $controller->getSwaggerSpecFragment();
+            $specs = json_decode($api, true);
+            foreach ($specs as $key => $spec) {
+                if (isset($results[$key])) {
+                    $results[$key] = array_merge($results[$key], $spec);
+                } else {
+                    $results[$key] = $spec;
+                }
+            }
+        }
+
+        // Return the fragment without the enclosing curly brackets
+        return substr(trim(json_encode($results, JSON_PRETTY_PRINT)), 1, -1);
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php b/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..ec71fb4d6aa2e3566956562f82dce12292fcc870
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Controller/ApiInterface.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * Additional functionality for API controllers.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) The National Library 2015.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Controller;
+
+/**
+ * Additional functionality for API controllers.
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+interface ApiInterface
+{
+    // define some status constants
+    const STATUS_OK = 'OK';                  // good
+    const STATUS_ERROR = 'ERROR';            // bad
+
+    /**
+     * Get Swagger specification JSON fragment for services provided by the
+     * controller
+     *
+     * @return \Zend\Http\Response
+     */
+    public function getSwaggerSpecFragment();
+}
diff --git a/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php b/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..4fcbf09d9c3dbb1e3a32e1e4e9330d7f08ec4d72
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Controller/ApiTrait.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * Additional functionality for API controllers.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) The National Library 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Controller;
+
+/**
+ * Additional functionality for API controllers.
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+trait ApiTrait
+{
+    /**
+     * Callback function in JSONP mode
+     *
+     * @var string
+     */
+    protected $jsonpCallback = null;
+
+    /**
+     * Whether to pretty-print JSON
+     *
+     * @var bool
+     */
+    protected $jsonPrettyPrint = false;
+
+    /**
+     * Type of output to use
+     *
+     * @var string
+     */
+    protected $outputMode = 'json';
+
+    /**
+     * Execute the request
+     *
+     * @param \Zend\Mvc\MvcEvent $e Event
+     *
+     * @return mixed
+     * @throws Exception\DomainException
+     */
+    public function onDispatch(\Zend\Mvc\MvcEvent $e)
+    {
+        // Add CORS headers and handle OPTIONS requests. This is a simplistic
+        // approach since we allow any origin. For more complete CORS handling
+        // a module like zfr-cors could be used.
+        $response = $this->getResponse();
+        $headers = $response->getHeaders();
+        $headers->addHeaderLine('Access-Control-Allow-Origin: *');
+        $request = $this->getRequest();
+        if ($request->getMethod() == 'OPTIONS') {
+            // Disable session writes
+            $this->disableSessionWrites();
+            $headers->addHeaderLine(
+                'Access-Control-Allow-Methods', 'GET, POST, OPTIONS'
+            );
+            $headers->addHeaderLine('Access-Control-Max-Age', '86400');
+
+            return $this->output(null, 204);
+        }
+        return parent::onDispatch($e);
+    }
+
+    /**
+     * Determine the correct output mode based on content negotiation or the
+     * view parameter
+     *
+     * @return void
+     */
+    protected function determineOutputMode()
+    {
+        $request = $this->getRequest();
+        $this->jsonpCallback
+            = $request->getQuery('callback', $request->getPost('callback', null));
+        $this->jsonPrettyPrint = $request->getQuery(
+            'prettyPrint', $request->getPost('prettyPrint', false)
+        );
+        $this->outputMode = empty($this->jsonpCallback) ? 'json' : 'jsonp';
+    }
+
+    /**
+     * Check whether access is denied and return the appropriate message or false.
+     *
+     * @param string $permission Permission to check
+     *
+     * @return \Zend\Http\Response|boolean
+     */
+    protected function isAccessDenied($permission)
+    {
+        $auth = $this->serviceLocator->get('ZfcRbac\Service\AuthorizationService');
+        if (!$auth->isGranted($permission)) {
+            return $this->output([], self::STATUS_ERROR, 403, 'Permission denied');
+        }
+        return false;
+    }
+
+    /**
+     * Send output data and exit.
+     *
+     * @param mixed  $data     The response data
+     * @param string $status   Status of the request
+     * @param int    $httpCode A custom HTTP Status Code
+     * @param string $message  Status message
+     *
+     * @return \Zend\Http\Response
+     * @throws \Exception
+     */
+    protected function output($data, $status, $httpCode = null, $message = '')
+    {
+        $response = $this->getResponse();
+        $headers = $response->getHeaders();
+        if ($httpCode !== null) {
+            $response->setStatusCode($httpCode);
+        }
+        if (null === $data) {
+            return $response;
+        }
+        $output = $data;
+        if (!isset($output['status'])) {
+            $output['status'] = $status;
+        }
+        if ($message && !isset($output['statusMessage'])) {
+            $output['statusMessage'] = $message;
+        }
+        $jsonOptions = $this->jsonPrettyPrint ? JSON_PRETTY_PRINT : 0;
+        if ($this->outputMode == 'json') {
+            $headers->addHeaderLine('Content-type', 'application/json');
+            $response->setContent(json_encode($output, $jsonOptions));
+            return $response;
+        } elseif ($this->outputMode == 'jsonp') {
+            $headers->addHeaderLine('Content-type', 'application/javascript');
+            $response->setContent(
+                $this->jsonpCallback . '(' . json_encode($output, $jsonOptions)
+                . ');'
+            );
+            return $response;
+        } else {
+            throw new \Exception('Invalid output mode');
+        }
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Controller/Factory.php b/module/VuFindApi/src/VuFindApi/Controller/Factory.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7fec320ccbf26ba8da4c3e03b1151506088fc18
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Controller/Factory.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Factory for controllers.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) The National Library of Finland 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Controller;
+use VuFindApi\Formatter\FacetFormatter;
+use VuFindApi\Formatter\RecordFormatter;
+use Zend\ServiceManager\ServiceManager;
+
+/**
+ * Factory for controllers.
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ *
+ * @codeCoverageIgnore
+ */
+class Factory
+{
+    /**
+     * Construct the ApiController.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return ApiController
+     */
+    public static function getApiController(ServiceManager $sm)
+    {
+        $controller = new ApiController();
+        $controller->addApi($sm->get('SearchApi'));
+        return $controller;
+    }
+
+    /**
+     * Construct the SearchApiController.
+     *
+     * @param ServiceManager $sm Service manager.
+     *
+     * @return SearchApiController
+     */
+    public static function getSearchApiController(ServiceManager $sm)
+    {
+        $recordFields = $sm->getServiceLocator()
+            ->get('VuFind\YamlReader')->get('SearchApiRecordFields.yaml');
+        $helperManager = $sm->getServiceLocator()->get('viewmanager')
+            ->getHelperManager();
+        $rf = new RecordFormatter($recordFields, $helperManager);
+        $controller = new SearchApiController($rf, new FacetFormatter());
+        return $controller;
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php b/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..c9aad10da6212937859b8cb666d8b72f779053d1
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Controller/SearchApiController.php
@@ -0,0 +1,371 @@
+<?php
+/**
+ * Search API Controller
+ *
+ * PHP Version 5
+ *
+ * Copyright (C) The National Library of Finland 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA    02111-1307    USA
+ *
+ * @category VuFind
+ * @package  Controller
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Controller;
+
+use VuFindApi\Formatter\FacetFormatter;
+use VuFindApi\Formatter\RecordFormatter;
+
+/**
+ * Search API Controller
+ *
+ * Controls the Search API functionality
+ *
+ * @category VuFind
+ * @package  Service
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class SearchApiController extends \VuFind\Controller\AbstractSearch
+    implements ApiInterface
+{
+    use ApiTrait;
+
+    /**
+     * Record formatter
+     *
+     * @var RecordFormatter
+     */
+    protected $recordFormatter;
+
+    /**
+     * Facet formatter
+     *
+     * @var FacetFormatter
+     */
+    protected $facetFormatter;
+
+    /**
+     * Default record fields to return if a request does not define the fields
+     *
+     * @var array
+     */
+    protected $defaultRecordFields = [];
+
+    /**
+     * Permission required for the record endpoint
+     *
+     * @var string
+     */
+    protected $recordAccessPermission = 'access.api.Record';
+
+    /**
+     * Permission required for the search endpoint
+     *
+     * @var string
+     */
+    protected $searchAccessPermission = 'access.api.Search';
+
+    /**
+     * Constructor
+     *
+     * @param RecordFormatter $rf Record formatter
+     * @param FacetFormatter  $ff Facet formatter
+     */
+    public function __construct(RecordFormatter $rf, FacetFormatter $ff)
+    {
+        $this->recordFormatter = $rf;
+        $this->facetFormatter = $ff;
+        foreach ($rf->getRecordFields() as $fieldName => $fieldSpec) {
+            if (!empty($fieldSpec['default'])) {
+                $this->defaultRecordFields[] = $fieldName;
+            }
+        }
+    }
+
+    /**
+     * Get Swagger specification JSON fragment for services provided by the
+     * controller
+     *
+     * @return string
+     */
+    public function getSwaggerSpecFragment()
+    {
+        $config = $this->getConfig();
+        $results = $this->getResultsManager()->get($this->searchClassId);
+        $options = $results->getOptions();
+        $params = $results->getParams();
+        $params->activateAllFacets();
+
+        $viewParams = [
+            'config' => $config,
+            'version' => \VuFind\Config\Version::getBuildVersion(),
+            'searchTypes' => $options->getBasicHandlers(),
+            'defaultSearchType' => $options->getDefaultHandler(),
+            'recordFields' => $this->recordFormatter->getRecordFieldSpec(),
+            'defaultFields' => $this->defaultRecordFields,
+            'facetConfig' => $params->getFacetConfig(),
+            'sortOptions' => $options->getSortOptions(),
+            'defaultSort' => $options->getDefaultSortByHandler()
+        ];
+        $json = $this->getViewRenderer()->render(
+            'searchapi/swagger', $viewParams
+        );
+        return $json;
+    }
+
+    /**
+     * Execute the request
+     *
+     * @param \Zend\Mvc\MvcEvent $e Event
+     *
+     * @return mixed
+     * @throws Exception\DomainException
+     */
+    public function onDispatch(\Zend\Mvc\MvcEvent $e)
+    {
+        // Add CORS headers and handle OPTIONS requests. This is a simplistic
+        // approach since we allow any origin. For more complete CORS handling
+        // a module like zfr-cors could be used.
+        $response = $this->getResponse();
+        $headers = $response->getHeaders();
+        $headers->addHeaderLine('Access-Control-Allow-Origin: *');
+        $request = $this->getRequest();
+        if ($request->getMethod() == 'OPTIONS') {
+            // Disable session writes
+            $this->disableSessionWrites();
+            $headers->addHeaderLine(
+                'Access-Control-Allow-Methods', 'GET, POST, OPTIONS'
+            );
+            $headers->addHeaderLine('Access-Control-Max-Age', '86400');
+
+            return $this->output(null, 204);
+        }
+        if (null !== $request->getQuery('swagger')) {
+            return $this->createSwaggerSpec();
+        }
+        return parent::onDispatch($e);
+    }
+
+    /**
+     * Record action
+     *
+     * @return \Zend\Http\Response
+     */
+    public function recordAction()
+    {
+        // Disable session writes
+        $this->disableSessionWrites();
+
+        $this->determineOutputMode();
+
+        if ($result = $this->isAccessDenied($this->recordAccessPermission)) {
+            return $result;
+        }
+
+        $request = $this->getRequest()->getQuery()->toArray()
+            + $this->getRequest()->getPost()->toArray();
+
+        if (!isset($request['id'])) {
+            return $this->output([], self::STATUS_ERROR, 400, 'Missing id');
+        }
+
+        $loader = $this->getServiceLocator()->get('VuFind\RecordLoader');
+        try {
+            if (is_array($request['id'])) {
+                $results = $loader->loadBatchForSource($request['id']);
+            } else {
+                $results[] = $loader->load($request['id']);
+            }
+        } catch (\Exception $e) {
+            return $this->output(
+                [], self::STATUS_ERROR, 400,
+                'Error loading record'
+            );
+        }
+
+        $response = [
+            'resultCount' => count($results)
+        ];
+        $requestedFields = $this->getFieldList($request);
+        if ($records = $this->recordFormatter->format($results, $requestedFields)) {
+            $response['records'] = $records;
+        }
+
+        return $this->output($response, self::STATUS_OK);
+    }
+
+    /**
+     * Search action
+     *
+     * @return \Zend\Http\Response
+     */
+    public function searchAction()
+    {
+        // Disable session writes
+        $this->disableSessionWrites();
+
+        $this->determineOutputMode();
+
+        if ($result = $this->isAccessDenied($this->searchAccessPermission)
+        ) {
+            return $result;
+        }
+
+        // Send both GET and POST variables to search class:
+        $request = $this->getRequest()->getQuery()->toArray()
+            + $this->getRequest()->getPost()->toArray();
+
+        if (isset($request['limit'])
+            && (!ctype_digit($request['limit'])
+            || $request['limit'] < 0 || $request['limit'] > 100)
+        ) {
+            return $this->output([], self::STATUS_ERROR, 400, 'Invalid limit');
+        }
+
+        // Sort by relevance by default
+        if (!isset($request['sort'])) {
+            $request['sort'] = 'relevance';
+        }
+
+        $requestedFields = $this->getFieldList($request);
+
+        $facetConfig = $this->getConfig('facets');
+        $hierarchicalFacets = isset($facetConfig->SpecialFacets->hierarchical)
+            ? $facetConfig->SpecialFacets->hierarchical->toArray()
+            : [];
+
+        $runner = $this->getServiceLocator()->get('VuFind\SearchRunner');
+        try {
+            $results = $runner->run(
+                $request,
+                $this->searchClassId,
+                function ($runner, $params, $searchId) use (
+                    $hierarchicalFacets, $request, $requestedFields
+                ) {
+                    foreach (isset($request['facet']) ? $request['facet'] : []
+                       as $facet
+                    ) {
+                        if (!isset($hierarchicalFacets[$facet])) {
+                            $params->addFacet($facet);
+                        }
+                    }
+                    if ($requestedFields) {
+                        $limit = isset($request['limit']) ? $request['limit'] : 20;
+                        $params->setLimit($limit);
+                    } else {
+                        $params->setLimit(0);
+                    }
+                }
+            );
+        } catch (\Exception $e) {
+            return $this->output([], self::STATUS_ERROR, 400, $e->getMessage());
+        }
+
+        // If we received an EmptySet back, that indicates that the real search
+        // failed due to some kind of syntax error, and we should display a
+        // warning to the user; otherwise, we should proceed with normal post-search
+        // processing.
+        if ($results instanceof \VuFind\Search\EmptySet\Results) {
+            return $this->output([], self::STATUS_ERROR, 400, 'Invalid search');
+        }
+
+        $response = ['resultCount' => $results->getResultTotal()];
+
+        $records = $this->recordFormatter->format(
+            $results->getResults(), $requestedFields
+        );
+        if ($records) {
+            $response['records'] = $records;
+        }
+
+        $requestedFacets = isset($request['facet']) ? $request['facet'] : [];
+        $hierarchicalFacetData = $this->getHierarchicalFacetData(
+            array_intersect($requestedFacets, $hierarchicalFacets)
+        );
+        $facets = $this->facetFormatter->format(
+            $request, $results, $hierarchicalFacetData
+        );
+        if ($facets) {
+            $response['facets'] = $facets;
+        }
+
+        return $this->output($response, self::STATUS_OK);
+    }
+
+    /**
+     * Get hierarchical facet data for the given facet fields
+     *
+     * @param array $facets Facet fields
+     *
+     * @return array
+     */
+    protected function getHierarchicalFacetData($facets)
+    {
+        if (!$facets) {
+            return [];
+        }
+        $results = $this->getResultsManager()->get('Solr');
+        $params = $results->getParams();
+        foreach ($facets as $facet) {
+            $params->addFacet($facet, null, false);
+        }
+        $params->initFromRequest($this->getRequest()->getQuery());
+
+        $facetResults = $results->getFullFieldFacets($facets, false, -1, 'count');
+
+        $facetHelper = $this->getServiceLocator()
+            ->get('VuFind\HierarchicalFacetHelper');
+
+        $facetList = [];
+        foreach ($facets as $facet) {
+            if (empty($facetResults[$facet]['data']['list'])) {
+                $facetList[$facet] = [];
+                continue;
+            }
+            $facetList[$facet] = $facetHelper->buildFacetArray(
+                $facet,
+                $facetResults[$facet]['data']['list'],
+                $results->getUrlQuery(),
+                false
+            );
+        }
+
+        return $facetList;
+    }
+
+    /**
+     * Get field list based on the request
+     *
+     * @param array $request Request params
+     *
+     * @return array
+     */
+    protected function getFieldList($request)
+    {
+        $fieldList = [];
+        if (isset($request['field'])) {
+            if (!empty($request['field']) && is_array($request['field'])) {
+                $fieldList = $request['field'];
+            }
+        } else {
+            $fieldList = $this->defaultRecordFields;
+        }
+        return $fieldList;
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Formatter/BaseFormatter.php b/module/VuFindApi/src/VuFindApi/Formatter/BaseFormatter.php
new file mode 100644
index 0000000000000000000000000000000000000000..e4d762b50582456c76e6db94536781059f42b1c2
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Formatter/BaseFormatter.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Base formatter for API responses
+ *
+ * PHP Version 5
+ *
+ * Copyright (C) The National Library of Finland 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA    02111-1307    USA
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Formatter;
+
+/**
+ * Base formatter for API responses
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class BaseFormatter
+{
+    /**
+     * Recursive function to filter array fields:
+     * - remove empty values
+     * - convert boolean values to 0/1
+     * - force numerically indexed (non-associative) arrays to have numeric keys.
+     *
+     * @param array $array Array to check
+     *
+     * @return void
+     */
+    protected function filterArrayValues(&$array)
+    {
+        foreach ($array as $key => &$value) {
+            if (is_array($value) && !empty($value)) {
+                $this->filterArrayValues($value);
+                $this->resetArrayIndices($value);
+            }
+
+            if ((is_array($value) && empty($value))
+                || (is_bool($value) && !$value)
+                || $value === null || $value === ''
+            ) {
+                unset($array[$key]);
+            } else if (is_bool($value) || $value === 'true' || $value === 'false') {
+                $array[$key] = $value === true || $value === 'true' ? 1 : 0;
+            }
+        }
+        $this->resetArrayIndices($array);
+    }
+
+    /**
+     * Reset numerical array indices.
+     *
+     * @param array $array Array
+     *
+     * @return void
+     */
+    protected function resetArrayIndices(&$array)
+    {
+        $isNumeric = count(array_filter(array_keys($array), 'is_string')) === 0;
+        if ($isNumeric) {
+            $array = array_values($array);
+        }
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Formatter/FacetFormatter.php b/module/VuFindApi/src/VuFindApi/Formatter/FacetFormatter.php
new file mode 100644
index 0000000000000000000000000000000000000000..cfc9a721be9400f3db1ed8df16f8cabae7fc0960
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Formatter/FacetFormatter.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * Facet formatter for API responses
+ *
+ * PHP Version 5
+ *
+ * Copyright (C) The National Library of Finland 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA    02111-1307    USA
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Formatter;
+use VuFind\Search\Base\Results;
+
+/**
+ * Facet formatter for API responses
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class FacetFormatter extends BaseFormatter
+{
+    /**
+     * Build an array of facet filters from the request params
+     *
+     * @param array $request Request params
+     *
+     * @return array
+     */
+    protected function buildFacetFilters($request)
+    {
+        $facetFilters = [];
+        if (isset($request['facetFilter'])) {
+            foreach ($request['facetFilter'] as $filter) {
+                list($facetField, $regex) = explode(':', $filter, 2);
+                $regex = trim($regex);
+                if (substr($regex, 0, 1)  == '"') {
+                    $regex = substr($regex, 1);
+                }
+                if (substr($regex, -1, 1) == '"') {
+                    $regex = substr($regex, 0, -1);
+                }
+                $facetFilters[$facetField][] = $regex;
+            }
+        }
+        return $facetFilters;
+    }
+
+    /**
+     * Match a facet item with the filters.
+     *
+     * @param array $facet   Facet
+     * @param array $filters Facet filters
+     *
+     * @return boolean
+     */
+    protected function matchFacetItem($facet, $filters)
+    {
+        $discard = true;
+        array_walk_recursive(
+            $facet,
+            function ($item, $key) use (&$discard, $filters) {
+                if ($discard && $key == 'value') {
+                    foreach ($filters as $filter) {
+                        $pattern = '/' . addcslashes($filter, '/') . '/';
+                        if (preg_match($pattern, $item) === 1) {
+                            $discard = false;
+                            break;
+                        }
+                    }
+                }
+            }
+        );
+        return !$discard;
+    }
+
+    /**
+     * Recursive function to create a facet value list for a single facet
+     *
+     * @param array $list    Facet items
+     * @param array $filters Facet filters
+     *
+     * @return array
+     */
+    protected function buildFacetValues($list, $filters = false)
+    {
+        $result = [];
+        $fields = [
+            'value', 'displayText', 'count',
+            'children', 'href', 'isApplied'
+        ];
+        foreach ($list as $value) {
+            $resultValue = [];
+            if ($filters && !$this->matchFacetItem($value, $filters)) {
+                continue;
+            }
+
+            foreach ($value as $key => $item) {
+                if (!in_array($key, $fields)) {
+                    continue;
+                }
+                if ($key == 'children') {
+                    if (!empty($item)) {
+                        $resultValue[$key]
+                            = $this->buildFacetValues(
+                                $item, $filters
+                            );
+                    }
+                } else {
+                    if ($key == 'displayText') {
+                        $key = 'translated';
+                    }
+                    $resultValue[$key] = $item;
+                }
+            }
+            $result[] = $resultValue;
+        }
+        return $result;
+    }
+
+    /**
+     * Create the result facet list
+     *
+     * @param array   $request               Request parameters
+     * @param Results $results               Search results
+     * @param array   $hierarchicalFacetData Hierarchical facet data
+     *
+     * @return array
+     */
+    public function format($request, Results $results, $hierarchicalFacetData)
+    {
+        if ($results->getResultTotal() == 0 || empty($request['facet'])) {
+            return [];
+        }
+
+        $filters = $this->buildFacetFilters($request);
+        $facets = $results->getFacetList();
+
+        // Format hierarchical facets, if any
+        if ($hierarchicalFacetData) {
+            foreach ($hierarchicalFacetData as $facet => $data) {
+                $facets[$facet]['list'] = $data;
+            }
+        }
+
+        // Add "missing" fields to non-hierarchical facets to make them similar
+        // to hierarchical facets for easier consumption.
+        $urlHelper = $results->getUrlQuery();
+        foreach ($facets as $facetKey => &$facetItems) {
+            if (isset($hierarchicalFacetData[$facetKey])) {
+                continue;
+            }
+
+            foreach ($facetItems['list'] as &$item) {
+                $href = !$item['isApplied']
+                    ? $urlHelper->addFacet(
+                        $facetKey, $item['value'], $item['operator']
+                    )->getParams(false) : $urlHelper->getParams(false);
+                $item['href'] = $href;
+            }
+        }
+        $this->filterArrayValues($facets);
+
+        $result = [];
+        foreach ($facets as $facetName => $facetData) {
+            $result[$facetName] = $this->buildFacetValues(
+                $facetData['list'],
+                !empty($filters[$facetName]) ? $filters[$facetName] : false
+            );
+        }
+        return $result;
+    }
+}
diff --git a/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php
new file mode 100644
index 0000000000000000000000000000000000000000..aba27117152ac7164fc886a0e3ec5a518db90a3d
--- /dev/null
+++ b/module/VuFindApi/src/VuFindApi/Formatter/RecordFormatter.php
@@ -0,0 +1,239 @@
+<?php
+/**
+ * Record formatter for API responses
+ *
+ * PHP Version 5
+ *
+ * Copyright (C) The National Library of Finland 2015-2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.    See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA    02111-1307    USA
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+namespace VuFindApi\Formatter;
+use VuFind\I18n\TranslatableString;
+use Zend\View\HelperPluginManager;
+
+/**
+ * Record formatter for API responses
+ *
+ * @category VuFind
+ * @package  API_Formatter
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
+ */
+class RecordFormatter extends BaseFormatter
+{
+    /**
+     * Record field definitions
+     *
+     * @var array
+     */
+    protected $recordFields;
+
+    /**
+     * View helper plugin manager
+     *
+     * @var HelperPluginManager
+     */
+    protected $helperManager;
+
+    /**
+     * Constructor
+     *
+     * @param array               $recordFields  Record field definitions
+     * @param HelperPluginManager $helperManager View helper plugin manager
+     */
+    public function __construct($recordFields, HelperPluginManager $helperManager
+    ) {
+        $this->recordFields = $recordFields;
+        $this->helperManager = $helperManager;
+    }
+
+    /**
+     * Get dedup IDs
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return array|null
+     */
+    protected function getDedupIds($record)
+    {
+        if (!($dedupData = $record->tryMethod('getDedupData'))) {
+            return null;
+        }
+        $result = [];
+        foreach ($dedupData as $item) {
+            $result[] = $item['id'];
+        }
+        return $result ? $result : null;
+    }
+
+    /**
+     * Get full record for a record as XML
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return string|null
+     */
+    protected function getFullRecord($record)
+    {
+        if ($xml = $record->tryMethod('getFilteredXML')) {
+            return $xml;
+        }
+        $rawData = $record->tryMethod('getRawData');
+        return isset($rawData['fullrecord']) ? $rawData['fullrecord'] : null;
+    }
+
+    /**
+     * Get raw data for a record as an array
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return array
+     */
+    protected function getRawData($record)
+    {
+        $rawData = $record->tryMethod('getRawData');
+
+        // Leave out spelling data
+        unset($rawData['spelling']);
+
+        return $rawData;
+    }
+
+    /**
+     * Get (relative) link to record page
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return string
+     */
+    protected function getRecordPage($record)
+    {
+        $urlHelper = $this->helperManager->get('recordLink');
+        return $urlHelper->getUrl($record);
+    }
+
+    /**
+     * Get URLs
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     *
+     * @return array
+     */
+    protected function getURLs($record)
+    {
+        $recordHelper = $this->helperManager->get('Record');
+        return $recordHelper($record)->getLinkDetails();
+    }
+
+    /**
+     * Get fields from a record as an array
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $record Record driver
+     * @param array                             $fields Fields to get
+     *
+     * @return array
+     */
+    protected function getFields($record, $fields)
+    {
+        $result = [];
+        foreach ($fields as $field) {
+            if (!isset($this->recordFields[$field])) {
+                continue;
+            }
+            $method = $this->recordFields[$field]['method'];
+            if (strncmp($method, 'Formatter::', 11) == 0) {
+                $value = $this->{substr($method, 11)}($record);
+            } else {
+                $value = $record->tryMethod($method);
+            }
+            $result[$field] = $value;
+        }
+        // Convert any translation aware string classes to strings
+        $translator = $this->helperManager->get('translate');
+        array_walk_recursive(
+            $result,
+            function (&$value) use ($translator) {
+                if (is_object($value)) {
+                    if ($value instanceof TranslatableString) {
+                        $value = [
+                            'value' => (string)$value,
+                            'translated' => $translator->translate($value)
+                        ];
+                    } else {
+                        $value = (string)$value;
+                    }
+                }
+            }
+        );
+
+        return $result;
+    }
+
+    /**
+     * Get record field definitions.
+     *
+     * @return array
+     */
+    public function getRecordFields()
+    {
+        return $this->recordFields;
+    }
+
+    /**
+     * Return record field specs for the Swagger specification
+     *
+     * @return array
+     */
+    public function getRecordFieldSpec()
+    {
+        $fields = array_map(
+            function ($item) {
+                if (isset($item['method'])) {
+                    unset($item['method']);
+                }
+                return $item;
+            },
+            $this->recordFields
+        );
+        return $fields;
+    }
+
+    /**
+     * Format the results.
+     *
+     * @param array $results         Results to process (array of record drivers)
+     * @param array $requestedFields Fields to include in response
+     *
+     * @return array
+     */
+    public function format($results, $requestedFields)
+    {
+        $records = [];
+        foreach ($results as $result) {
+            $records[] = $this->getFields($result, $requestedFields);
+        }
+
+        $this->filterArrayValues($records);
+
+        return $records;
+    }
+}
diff --git a/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/FacetFormatterTest.php b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/FacetFormatterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4c15ee7ceb7e76e1f54817c1b02357e095de5182
--- /dev/null
+++ b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/FacetFormatterTest.php
@@ -0,0 +1,325 @@
+<?php
+
+/**
+ * Unit tests for facet formatter.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+namespace VuFindTest\Formatter;
+
+use VuFindTest\Search\TestHarness\Options;
+use VuFindTest\Search\TestHarness\Params;
+use VuFindTest\Search\TestHarness\Results;
+
+/**
+ * Unit tests for facet formatter.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+class FacetFormatterTest extends \VuFindTest\Unit\TestCase
+{
+    /**
+     * Get fake facet data.
+     *
+     * @param bool $includeOr Include OR facet data?
+     *
+     * @return array
+     */
+    protected function getFakeFacetData($includeOr = false)
+    {
+        $data = [
+            'foo' => [
+                'label' => 'Foo Facet',
+                'list' => [
+                    [
+                        'value' => 'bar',
+                        'displayText' => 'translated(bar)',
+                        'count' => 100,
+                        'operator' => 'AND',
+                        'isApplied' => false,
+                    ],
+                    [
+                        'value' => 'baz',
+                        'displayText' => 'translated(baz)',
+                        'count' => 150,
+                        'operator' => 'AND',
+                        'isApplied' => true,
+                    ]
+                ]
+            ],
+            'xyzzy' => [
+                'label' => 'Xyzzy Facet',
+                'list' => [
+                    [
+                        'value' => 'val1',
+                        'displayText' => 'translated(val1)',
+                        'count' => 10,
+                        'operator' => 'OR',
+                        'isApplied' => false,
+                    ],
+                    [
+                        'value' => 'val2',
+                        'displayText' => 'translated(val2)',
+                        'count' => 15,
+                        'operator' => 'OR',
+                        'isApplied' => true,
+                    ],
+                    [
+                        'value' => 'val3',
+                        'displayText' => 'translated(val3)',
+                        'count' => 5,
+                        'operator' => 'OR',
+                        'isApplied' => true,
+                    ]
+                ]
+            ]
+        ];
+        if (!$includeOr) {
+            unset($data['xyzzy']);
+            unset($data['hierarchical_xyzzy']);
+        }
+        return $data;
+    }
+
+    /**
+     * Get fake hierarchical facet data.
+     *
+     * @param array $request   Request params
+     * @param bool  $includeOr Include OR facet data?
+     *
+     * @return array
+     */
+    protected function getFakeHierarchicalFacetData($request, $includeOr = false)
+    {
+        $data = [
+            'hierarchical_foo' => [
+                [
+                    'value' => '0/bar/',
+                    'displayText' => 'translated(bar)',
+                    'count' => 100,
+                    'operator' => 'AND',
+                    'isApplied' => false,
+                ],
+                [
+                    'value' => '1/bar/cookie/',
+                    'displayText' => 'translated(cookie)',
+                    'count' => 150,
+                    'operator' => 'AND',
+                    'isApplied' => true,
+                ]
+            ],
+            'hierarchical_xyzzy' => [
+                [
+                    'value' => '0/val1/',
+                    'displayText' => 'translated(val1)',
+                    'count' => 10,
+                    'operator' => 'OR',
+                    'isApplied' => false,
+                ],
+                [
+                    'value' => '1/val1/val2/',
+                    'displayText' => 'translated(val2)',
+                    'count' => 15,
+                    'operator' => 'OR',
+                    'isApplied' => true,
+                ]
+            ]
+        ];
+        if (!$includeOr) {
+            unset($data['hierarchical_xyzzy']);
+        }
+
+        $results = [];
+        $helper = new \VuFind\Search\Solr\HierarchicalFacetHelper();
+        $configManager = $this->getMock('VuFind\Config\PluginManager');
+        $params = new Params(new Options($configManager), $configManager);
+        $requestParams = new \Zend\StdLib\Parameters($request);
+        $params->initFromRequest($requestParams);
+        $factory = new \VuFind\Search\Factory\UrlQueryHelperFactory();
+        $urlQuery = $factory->fromParams($params);
+        foreach ($data as $facet => $values) {
+            $results[$facet] = $helper->buildFacetArray(
+                $facet, $values, $urlQuery, false
+            );
+        }
+
+        return $results;
+    }
+
+    /**
+     * Get fake results object.
+     *
+     * @param array $request   Request parameters.
+     * @param array $facetData Facet data to inject into results.
+     *
+     * @return Results
+     */
+    protected function getFakeResults($request, $facetData)
+    {
+        $configManager = $this->getMock('VuFind\Config\PluginManager');
+        $params = new Params(new Options($configManager), $configManager);
+        $params->initFromRequest(new \Zend\Stdlib\Parameters($request));
+        return new Results($params, 100, $facetData);
+    }
+
+    /**
+     * Test the facet formatter
+     *
+     * @return void
+     */
+    public function testFormatter()
+    {
+        $formatter = new \VuFindApi\Formatter\FacetFormatter();
+        $request = [
+            'facet' => ['foo', 'hierarchical_foo'],
+            'filter' => ['foo:baz', 'hierarchical_foo:1/bar/cookie/'],
+        ];
+        $formatted = $formatter->format(
+            $request, $this->getFakeResults($request, $this->getFakeFacetData()),
+            $this->getFakeHierarchicalFacetData($request)
+        );
+
+        $expected = [
+            'foo' => [
+                [
+                    'value' => 'bar',
+                    'translated' => 'translated(bar)',
+                    'count' => 100,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=foo%3A%22bar%22',
+                ],
+                [
+                    'value' => 'baz',
+                    'translated' => 'translated(baz)',
+                    'count' => 150,
+                    'isApplied' => 1,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22',
+                ],
+            ],
+            'hierarchical_foo' => [
+                [
+                    'value' => '0/bar/',
+                    'translated' => 'translated(bar)',
+                    'count' => 100,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=hierarchical_foo%3A%220%2Fbar%2F%22',
+                    'children' => [
+                        [
+                            'value' => '1/bar/cookie/',
+                            'translated' => 'translated(cookie)',
+                            'count' => 150,
+                            'isApplied' => 1,
+                            'href' => '?filter%5B%5D=foo%3A%22baz%22',
+                        ]
+                    ]
+                ]
+            ],
+        ];
+        $this->assertEquals($expected, $formatted);
+    }
+
+    /**
+     * Test the facet formatter with filtering turned on
+     *
+     * @return void
+     */
+    public function testFormatterWithFiltering()
+    {
+        $formatter = new \VuFindApi\Formatter\FacetFormatter();
+        $request = [
+            'facet' => ['foo', 'xyzzy'],
+            'filter' => ['foo:baz', 'hierarchical_foo:1/bar/cookie/', '~xyzzy:val2', '~xyzzy:val3', 'hierarchical_xyzzy:1/val1/val2/'],
+            'facetFilter' => ['foo:..z', 'xyzzy:val(2|3)'],
+        ];
+        $formatted = $formatter->format(
+            $request, $this->getFakeResults($request, $this->getFakeFacetData(true)),
+            $this->getFakeHierarchicalFacetData($request, true)
+        );
+
+        $expected = [
+            'foo' => [
+                [
+                    'value' => 'baz',
+                    'translated' => 'translated(baz)',
+                    'count' => 150,
+                    'isApplied' => 1,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22',
+                ],
+            ],
+            'xyzzy' => [
+                [
+                    'value' => 'val2',
+                    'translated' => 'translated(val2)',
+                    'count' => 15,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22',
+                    'isApplied' => 1,
+                ],
+                [
+                    'value' => 'val3',
+                    'translated' => 'translated(val3)',
+                    'count' => 5,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22',
+                    'isApplied' => 1,
+                ],
+            ],
+            'hierarchical_foo' => [
+                [
+                    'value' => '0/bar/',
+                    'translated' => 'translated(bar)',
+                    'count' => 100,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22&filter%5B%5D=hierarchical_foo%3A%220%2Fbar%2F%22',
+                    'children' => [
+                        [
+                            'value' => '1/bar/cookie/',
+                            'translated' => 'translated(cookie)',
+                            'count' => 150,
+                            'isApplied' => 1,
+                            'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22',
+                        ]
+                    ]
+                ]
+            ],
+            'hierarchical_xyzzy' => [
+                [
+                    'value' => '0/val1/',
+                    'translated' => 'translated(val1)',
+                    'count' => 10,
+                    'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22&filter%5B%5D=%7Ehierarchical_xyzzy%3A%220%2Fval1%2F%22',
+                    'children' => [
+                        [
+                            'value' => '1/val1/val2/',
+                            'translated' => 'translated(val2)',
+                            'count' => 15,
+                            'isApplied' => 1,
+                            'href' => '?filter%5B%5D=foo%3A%22baz%22&filter%5B%5D=hierarchical_foo%3A%221%2Fbar%2Fcookie%2F%22&filter%5B%5D=%7Exyzzy%3A%22val2%22&filter%5B%5D=%7Exyzzy%3A%22val3%22&filter%5B%5D=hierarchical_xyzzy%3A%221%2Fval1%2Fval2%2F%22',
+                        ]
+                    ]
+                ]
+            ]
+        ];
+        $this->assertEquals($expected, $formatted);
+    }
+}
diff --git a/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/RecordFormatterTest.php b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/RecordFormatterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f3436209a4a27066d17310ef5c075cf8bc0a9d03
--- /dev/null
+++ b/module/VuFindApi/tests/unit-tests/src/VuFindTest/Formatter/RecordFormatterTest.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * Unit tests for record formatter.
+ *
+ * PHP version 5
+ *
+ * Copyright (C) Villanova University 2016.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+namespace VuFindTest\Formatter;
+use VuFindApi\Formatter\RecordFormatter;
+use VuFind\I18n\TranslatableString;
+
+/**
+ * Unit tests for record formatter.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+class RecordFormatterTest extends \VuFindTest\Unit\TestCase
+{
+    /**
+     * Get default configuration to use in tests when no overrides are specified.
+     *
+     * @return array
+     */
+    protected function getDefaultDefs()
+    {
+        return [
+            'cleanDOI' => [
+                'method' => 'getCleanDOI',
+                'description' => 'First valid DOI',
+                'type' => 'string'
+            ],
+            'dedupIds' => [
+                'method' => 'Formatter::getDedupIds',
+                'description' => 'IDs of all records deduplicated',
+                'type' => 'array',
+                'items' => ['type' => 'string']
+            ],
+            'fullRecord' => ['method' => 'Formatter::getFullRecord'],
+            'rawData' => ['method' => 'Formatter::getRawData'],
+            'buildings' => ['method' => 'getBuilding'],
+            'recordPage' => ['method' => 'Formatter::getRecordPage']
+        ];
+    }
+
+    /**
+     * Get a helper plugin manager for the RecordFormatter.
+     *
+     * @return \Zend\View\HelperPluginManager
+     */
+    protected function getHelperPluginManager()
+    {
+        $hm = new \Zend\View\HelperPluginManager();
+        $hm->setService('translate', new \VuFind\View\Helper\Root\Translate());
+
+        $mockRecordLink = $this->getMockBuilder('VuFind\View\Helper\Root\RecordLink')
+            ->disableOriginalConstructor()->getMock();
+        $mockRecordLink->expects($this->any())->method('getUrl')
+            ->will($this->returnValue('http://record'));
+        $hm->setService('recordLink', $mockRecordLink);
+        return $hm;
+    }
+
+    /**
+     * Get a formatter to test with.
+     *
+     * @param array $defs Configuration for formatter
+     *
+     * @return RecordFormatter
+     */
+    protected function getFormatter($defs = null)
+    {
+        return new RecordFormatter(
+            $defs ?: $this->getDefaultDefs(), $this->getHelperPluginManager()
+        );
+    }
+
+    /**
+     * Get a record driver to test with.
+     *
+     * @return \VuFindTest\RecordDriver\TestHarness
+     */
+    protected function getDriver()
+    {
+        $driver = new \VuFindTest\RecordDriver\TestHarness();
+        $driver->setRawData(
+            [
+                'CleanDOI' => 'foo',
+                'DedupData' => [['id' => 'bar']],
+                'fullrecord' => 'xyzzy',
+                'spelling' => 's',
+                'Building' => ['foo', new TranslatableString('bar', 'xyzzy')]
+            ]
+        );
+        return $driver;
+    }
+
+    /**
+     * Test the record formatter.
+     *
+     * @return void
+     */
+    public function testFormatter()
+    {
+        $formatter = $this->getFormatter();
+
+        $driver = $this->getDriver();
+
+        // Test requesting no fields.
+        $this->assertEquals([], $formatter->format([$driver], []));
+
+        // Test requesting fields:
+        $results = $formatter->format(
+            [$driver], array_keys($this->getDefaultDefs())
+        );
+        $expectedRaw = $driver->getRawData();
+        unset($expectedRaw['spelling']);
+        $expectedRaw['Building'] = [
+            'foo', ['value' => 'bar', 'translated' => 'xyzzy']
+        ];
+        $expected = [
+            [
+                'cleanDOI' => 'foo',
+                'dedupIds' => ['bar'],
+                'fullRecord' => 'xyzzy',
+                'rawData' => $expectedRaw,
+                'buildings' => ['foo', ['value' => 'bar', 'translated' => 'xyzzy']],
+                'recordPage' => 'http://record'
+            ],
+        ];
+        $this->assertEquals($expected, $results);
+
+        // Test filtered XML
+        $filtered = '<filtered></filtered>';
+        $driver->setFilteredXML($filtered);
+        $results = $formatter->format(
+            [$driver], array_keys($this->getDefaultDefs())
+        );
+        $expected[0]['fullRecord'] = $filtered;
+        $expected[0]['rawData']['FilteredXML'] = $filtered;
+        $this->assertEquals($expected, $results);
+    }
+
+    /**
+     * Test getting the field specs.
+     *
+     * @return void
+     */
+    public function testFieldSpecs()
+    {
+        $formatter = $this->getFormatter();
+        $results = $formatter->getRecordFieldSpec();
+        $expected = [
+            'cleanDOI' => [
+                'description' => 'First valid DOI',
+                'type' => 'string'
+            ],
+            'dedupIds' => [
+                'description' => 'IDs of all records deduplicated',
+                'type' => 'array',
+                'items' => ['type' => 'string']
+            ],
+            'fullRecord' => [],
+            'rawData' => [],
+            'buildings' => [],
+            'recordPage' => []
+        ];
+        $this->assertEquals($expected, $results);
+    }
+}
diff --git a/themes/root/templates/api/swagger.phtml b/themes/root/templates/api/swagger.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..fc0015b764931f151f343b4dd4b564210ed1998c
--- /dev/null
+++ b/themes/root/templates/api/swagger.phtml
@@ -0,0 +1,14 @@
+<? // This template is a JSON Swagger specification ?>
+<? $basePath = rtrim($this->url('home'), '/') . '/api/v1'; ?>
+{
+    "swagger": "2.0",
+    "info": {
+        "title": "<?=$this->config->Site->title ?>",
+        "version": "<?=$this->version ?>"
+    },
+    "basePath": "<?=$basePath ?>",
+    "produces": [
+        "application/json"
+    ],
+    <?=$this->specs ?>
+}
\ No newline at end of file
diff --git a/themes/root/templates/searchapi/swagger.phtml b/themes/root/templates/searchapi/swagger.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..b48bcb55525104187920b13410e4f232df9add59
--- /dev/null
+++ b/themes/root/templates/searchapi/swagger.phtml
@@ -0,0 +1,354 @@
+<? // This template is a JSON fragment of a complete Swagger specification ?>
+<? ob_start(); ?>
+                    {
+                        "name": "field[]",
+                        "in": "query",
+                        "description": "Fields to return.<? if ($this->defaultFields): ?> If not specified, a set of default fields is returned.\n\nThe default fields are:\n- <?=implode('\n- ', array_map('addslashes', $this->defaultFields)) ?><? endif; ?>",
+                        "type": "array",
+                        "collectionFormat": "multi",
+                        "items": {
+                            "type": "string"
+                        }
+                    }
+<? $field = ob_get_contents(); ?>
+<? ob_end_clean(); ?>
+<? ob_start(); ?>
+                    {
+                        "name": "prettyPrint",
+                        "in": "query",
+                        "description": "Whether to pretty-print the response. Useful for observing the results in a browser.",
+                        "type": "boolean",
+                        "default": "false"
+                    },
+                    {
+                        "name": "lng",
+                        "in": "query",
+                        "description": "Language for returned translated strings.",
+                        "type": "string",
+                        "enum": [<?=implode(', ', array_map(function ($v) { return '"' . addslashes($v) . '"'; }, array_keys($this->config->Languages->toArray()))) ?>],
+                        "default": "<?=addslashes($this->config->Site->language) ?>"
+                    },
+                    {
+                        "name": "callback",
+                        "in": "query",
+                        "description": "A callback that can be used for JSONP.",
+                        "type": "string"
+                    }
+<? $commonFields = ob_get_contents(); ?>
+<? ob_end_clean(); ?>
+{
+    "paths": {
+        "/record": {
+            "get": {
+                "summary": "Fetch records",
+                "description": "Return a single record or multiple records. POST method may also be used if sending a long request.",
+                "parameters": [
+                    {
+                        "name": "id",
+                        "in": "query",
+                        "description": "A single record ID",
+                        "required": false,
+                        "type": "string"
+                    },
+                    {
+                        "name": "id[]",
+                        "in": "query",
+                        "description": "Multiple record IDs",
+                        "required": false,
+                        "type": "array",
+                        "collectionFormat": "multi",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+<?=$field ?>,
+<?=$commonFields ?>
+                ],
+                "tags": [
+                    "Record"
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Response containing result count and records",
+                        "schema": {
+                            "$ref": "#/definitions/SearchResponse"
+                        }
+                    },
+                    "default": {
+                        "description": "Error",
+                        "schema": {
+                            "$ref": "#/definitions/Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/search": {
+            "get": {
+                "summary": "Search the index",
+                "description": "Search the index with given terms and filters. POST method may also be used if sending a long request.\n\nThe URL syntax here is the as the one used in VuFind user interface. It is possible to make a search in VuFind and copy the query parameters here to make the same search via the API.",
+                "parameters": [
+                    {
+                        "name": "lookfor",
+                        "in": "query",
+                        "description": "Search terms. May be a single term, multiple words or a complex query containing boolean operators (AND, OR, NOT), quotes etc. Terms may also be prefixed with a field name (e.g. author:smith) to target only that field (see also type parameter).",
+                        "required": false,
+                        "type": "string"
+                    },
+                    {
+                        "name": "type",
+                        "in": "query",
+                        "description": "Search type. The following search types are available:\n- <?=implode('\n- ', array_map('addslashes', array_keys($this->searchTypes))) ?>",
+                        "required": false,
+                        "type": "string",
+                        "default": "<?=addslashes($this->defaultSearchType) ?>"
+                    },
+<?=$field ?>,
+                    {
+                        "name": "filter[]",
+                        "in": "query",
+                        "description": "Filter queries. Repeat for every filter.\nThe format for a filter is field:value.\n\n'AND' filtering is used by default. 'OR' or 'NOT' filtering can be used by prepending the field with '~' (OR) or '-' (NOT).",
+                        "type": "array",
+                        "collectionFormat": "multi",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "facet[]",
+                        "in": "query",
+                        "description": "Fields to facet on. Repeat for every field. <? if ($this->facetConfig): ?>At least the following fields are available for faceting (there may be additional facetable index fields not published here):\n- <?=implode('\n- ', array_map('addslashes', array_keys($this->facetConfig))) ?><? endif; ?>",
+                        "type": "array",
+                        "collectionFormat": "multi",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "facetFilter[]",
+                        "in": "query",
+                        "description": "Faceting result filters. Contains regular expressions that the facet must match for it to be returned. The result will include the matched facet items and for hierarchical facets any ancestor values.\nFormat for a facet filter is field:regexp, e.g. `author_facet:Sm.*th`.\n\n**N.B.**The filtered facet needs to be present in the list of returned facets (facet[] parameter).",
+                        "type": "array",
+                        "collectionFormat": "multi",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "sort",
+                        "in": "query",
+                        "description": "Sort method.",
+                        "type": "string",
+                        "enum": [<?=implode(', ', array_map(function ($v) { return '"' . addslashes($v) . '"'; }, array_keys($this->sortOptions))) ?>],
+                        "default": "<?=addslashes($this->defaultSort) ?>"
+                    },
+                    {
+                        "name": "page",
+                        "in": "query",
+                        "description": "Record page (first page is 1).",
+                        "type": "integer",
+                        "minimum": 1,
+                        "default": 1
+                    },
+                    {
+                        "name": "limit",
+                        "in": "query",
+                        "description": "Records to return per page. Set to 0 to return no records.",
+                        "type": "integer",
+                        "minimum": 0,
+                        "maximum": 100,
+                        "default": 20
+                    },
+<?=$commonFields ?>
+                ],
+                "tags": [
+                    "Search"
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Response containing result count, records and/or facets.",
+                        "schema": {
+                            "$ref": "#/definitions/SearchResponse"
+                        }
+                    },
+                    "default": {
+                        "description": "Error",
+                        "schema": {
+                            "$ref": "#/definitions/Error"
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "definitions": {
+        "Authors": {
+            "type": "object",
+            "properties": {
+                "main": {
+                    "description": "Main authors",
+                    "type": "array",
+                    "items": {
+                      "$ref": "#/definitions/AuthorWithRoles"
+                    }
+                },
+                "secondary": {
+                    "description": "Secondary authors",
+                    "type": "array",
+                    "items": {
+                      "$ref": "#/definitions/AuthorWithRoles"
+                    }
+                },
+                "corporate": {
+                    "description": "Corporate authors",
+                    "type": "array",
+                    "items": {
+                      "$ref": "#/definitions/AuthorWithRoles"
+                    }
+                }
+            }
+        },
+        "AuthorWithRoles": {
+            "type": "object",
+            "properties": {
+                "Author name": {
+                    "type": "array",
+                    "description": "Author's roles",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
+        "Error": {
+            "type": "object",
+            "properties": {
+                "status": {
+                    "description": "Status code",
+                    "type": "string",
+                    "enum": ["ERROR"]
+                },
+                "statusMessage": {
+                    "description": "A descriptive error message",
+                    "type": "string"
+                }
+            },
+            "required": ["status", "statusMessage"]
+        },
+        "Facet": {
+            "type": "object",
+            "properties": {
+                "value": {
+                    "description": "Facet value",
+                    "type": "string"
+                },
+                "translated": {
+                    "description": "Translated facet value",
+                    "type": "string"
+                },
+                "count": {
+                    "description": "Count of records that can be found if the facet is added to the search as a filter",
+                    "type": "integer"
+                },
+                "href": {
+                    "description": "The current search with the facet added as a filter",
+                    "type": "string"
+                },
+                "isApplied": {
+                    "description": "True if if the facet is in use in the filter parameter of the query",
+                    "type": "boolean"
+                },
+                "children": {
+                    "description": "For hierarchical facets, any child facets",
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/Facet"
+                    }
+                }
+            }
+        },
+        "Record": {
+            "type": "object",
+            "properties": <?=json_encode($this->recordFields) ?>
+        },
+        "RecordLink": {
+            "type": "object",
+            "properties": {
+                "title": {
+                    "description": "Link title",
+                    "type": "string"
+                },
+                "value": {
+                    "description": "Link value",
+                    "type": "string"
+                },
+                "link": {
+                    "description": "Actual link",
+                    "$ref": "#/definitions/Link"
+                }
+            }
+        },
+        "Link": {
+            "type": "object",
+            "properties": {
+                "type": {
+                    "description": "Link type (e.g. id, isn or title)",
+                    "type": "string"
+                },
+                "value": {
+                    "description": "Link value (e.g. record ID, ISBN or title)",
+                    "type": "string"
+                },
+                "exclude": {
+                    "description": "Record IDs that need to be excluded from the results",
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
+        "SearchResponse": {
+            "type": "object",
+            "properties": {
+                "resultCount": {
+                    "description": "Number of results",
+                    "type": "integer"
+                },
+                "records": {
+                    "description": "Records",
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/Record"
+                    }
+                },
+                "facets": {
+                    "description": "Facets",
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/Facet"
+                    }
+                },
+                "status": {
+                    "description": "Status code",
+                    "type": "string",
+                    "enum": ["OK"]
+                }
+            },
+            "required": ["resultCount", "status"]
+        },
+        "Url": {
+            "type": "object",
+            "properties": {
+                "url": {
+                    "description": "URL",
+                    "type": "string"
+                },
+                "desc": {
+                    "description": "URL Description",
+                    "type": "string"
+                }
+            }
+        }
+    }
+}
\ No newline at end of file